5. Generics#
At the very beginning, let us follow a clear distinction made in [van Rossum et al., 2014] between a class and a type.
A type
A type is a variable or function annotation, a static attribute used for static type-checking. You cannot create instances of types!
A class
A class is a kind of factory for creating instances; classes are a runtime concept!
A class is a type
Every class is also a type, but not vice-versa!
5.1. Type Hints#
Let us recall that Python supports type hints as a completely voluntary mechanism. Although Python’s makers claim that they will never be mandatory, type hints are nevertheless an extremely useful concept which (quite easily) enables programmers to avoid potentially catastrophic logical errors.
To make this concrete, let us consider a banking system. Access to money should be granted only to an authorized user, represented by an AuthorizedUser class, not just any User instance. Remember, Python does not require explicit types; hence, provided no extra validation logic is implemented, a program could accidentally grant access to an unauthorized user, leading to security vulnerabilities.
class User:
def __init__(self, name: str):
self.name = name
class AuthorizedUser(User):
def __init__(self, name: str, auth_token: str):
super().__init__(name)
self.auth_token = auth_token
def withdraw_money(user: AuthorizedUser, amount: float) -> bool:
# Only authorized users should access this
# Without type hints, static type check—another watchdog—will fail
print(f"Withdrawing ${amount} for {user.name}")
return True
regular_user = User("John")
withdraw_money(regular_user, 1000.0) # Static-check type error!
# This is correct
authorized_user = AuthorizedUser("Jane", "token123")
withdraw_money(authorized_user, 1000.0) # OK
Thanks to type hints, tools for static code analysis such as MyPy can capture such errors on demand.
Type hints
In Python, type hints are given after a : (colon) symbol just after the name of a variable or function parameter, or after -> when representing the type returned by a function:
age: int = 100 # we annotate `age` as a variable containing `int` values
or
def process(data: list, eps: float, title: str) -> str:
# some logic here
return f"Processed {title}"
5.2. Type Aliases#
When needed, we can define aliases for a type, e.g., to make types more compact and reusable:
1type ListOrSet = list | set
2
3def process(item: ListOrSet): # instead of item: list | set
4 # some logic here
or code — more meaningful:
1type Minute = int
2
3def sleep(time: Minute) -> None:
4 # some logic here
5 pass
In the above example, it is clear that the argument’s integer value represents minutes, not seconds or milliseconds, so the code is clearer and more concise than:
1def sleep(time_minutes: int) -> None:
2 # some logic here
3 pass
Let us now see another example:
1type Minute = int
2type Retry = int
3
4def retry(backoff: Minute, max_retry: Retry) -> None:
5 # some logic here
6 pass
In the code above, nothing prevents us from running the code:
1backoff_time: Minute = 10
2retry_count: Retry = 5
3
4# Accidentally swapping the arguments!
5retry(retry_count, backoff_time) # Static type checker won't complain!
As you can see, static type checking alone doesn’t prevent this confusion because both are just int aliases. When you need static type-checking to be more conservative, you can define simple subclasses to enforce the right types:
1class Minute(int):
2 pass
3
4class Retry(int):
5 pass
This forces you to create instances explicitly:
1backoff_time: Minute = Minute(10)
2retry_count: Retry = Retry(5)
3
4# Now this will fail at static type checking
5retry(retry_count, backoff_time) # Type error!
Then, running:
mypy our_code.py
will, as expected, result in the following errors:
our_code.py:5: error: Argument 1 to "retry" has incompatible type "Retry"; expected "Minute" [arg-type]
our_code.py:5: error: Argument 2 to "retry" has incompatible type "Minute"; expected "Retry" [arg-type]
However, this approach employs explicit creation of a brand new class. That is unnecessary and requires extra computation at runtime. Hence, you can define a new type using the NewType helper construct:
1from typing import NewType
2
3Minute = NewType("Minute", int)
4Retry = NewType("Retry", int)
Remember, NewType must take only a name (it should be the same as the variable it is assigned to) and its superclass!
NewType vs Real Classes
NewType return value is used only for static type checking and cannot be subclassed. If you need inheritance, methods, or runtime behavior, use real class inheritance instead! NewType outputs cannot be used in subclassing or runtime checks with isinstance!
5.3. What Are Generic Types?#
While type aliases and NewType help us create more meaningful names for existing types, they still work with concrete, fixed types. What if we need a data structure that can work with any type: integers today, strings tomorrow, or custom objects next week (while maintaining type safety)? This is where generic types come into play.
First, let us see how the Cambridge Dictionary defines the adjective generic [generic, 2025]:
generic: shared by, typical of, or relating to a whole group of similar things, rather than to any particular thing
How do we translate that into the language of programmers? In software engineering we most frequently speak about generic types or generic classes.
Generic types
Generic types are types that are parameterized by other types, e.g., a list of integers, a stack of strings, or a dictionary mapping strings to users!
5.3.1. Why Do We Need Generics?#
Imagine you’re building a data structure like a stack. Without generics, you might write an IntStack class dealing with integers, but what if you need a stack of strings? Or floats? You’d have to write StringStack, FloatStack, etc. This is repetitive and error-prone. Certainly, you can delegate the logic to a base class, but then classes such as StringStack or FloatStack are just dummy subclasses to satisfy type checks (provided the logic is consistent). Generics solve this problem by allowing you to write one stack that works with any type, provided they share similar logic!
Generics vs Inheritance
Generics (Stack[int], Stack[str]): Same behavior, different data types. Write one class, use it type-safely with many types.
Inheritance (Dog extends Animal): Different behaviors. A Dog has specific methods that Animal doesn’t.
These are complementary tools—use generics to avoid duplication, use inheritance to specialize behavior!
5.4. Creating a Generic#
In Python, generic types are usually (but not necessarily) related to collection classes, e.g., a set of floats or a stack of requests, where we specify what type of objects a collection manages.
Since Python 3.12, where a modern and simpler form of defining generics has been introduced by PEP 695 [Traut, 2022], we define generic classes as:
1class Graph[T]:
2
3 def __init__(self) -> None:
4 self.nodes: list[T] = []
5
6 def add(self, item: T) -> None:
7 self.nodes.append(item)
8
9 def get_all(self) -> list[T]:
10 return self.nodes
and generic functions as:
1def join[T](arg1: T, arg2: T) -> T:
2 # some logic here
3 return arg1
This mysterious variable T in both listings is actually the placeholder for the type the generic class or function is parameterized with. For example, in the code below:
res = join("a", "b")
the T will be bound to str as we passed two string literals: "a" and "b".
Mixed types in generics
Note that we can call join(1, "b") and MyPy will not complain. In this case, the placeholder parameter T is bound to the object type, whose subclasses are both int and str. That is why the static type checker doesn’t necessarily complain about it. You can check type binding by adding the statement reveal_type(res). Remember, it will not work at runtime and only during static type checking with the mypy command.
Type parameter name
The name of the type placeholder need not be T. It actually can be any arbitrary valid Python identifier. If your generic class or function takes multiple parameter placeholders, they need to be unique:
1class MyClass[T1, T2]:
2 pass
Parameterizing generics
With functions, we cannot explicitly state the type for a generic (the type is inferred automatically) but for classes, we can. In fact, we can initialize our Graph class in multiple ways:
1# All of these are valid
2graph_1 = Graph() # Type is inferred
3graph_2: Graph[int] = Graph() # Explicit annotation
4graph_3 = Graph[int]() # Runtime parameterization
5
6# Usage
7graph_2.add(42) # OK
8graph_2.add("hello") # Type checker will complain!
5.5. Upper-Bounded Parameter Type#
We already know that our generic will work without type-check complaints with any kind of type (even a mixture of int and str). There are, however, many cases where the type needs to be limited. For example, a generic method should be valid only for str and its subclasses. To achieve this, we can specify an upper bound for our parameter placeholder by means of type annotation. We simply type-annotate our placeholder type:
1class Graph[T: str]:
2 # the rest of the code
and it will work in either case:
1class Str2(str):
2 pass
3
4g1 = Graph[str]()
5g2 = Graph[Str2]()
5.6. Type Constraints#
Besides bounding a generic type placeholder to a particular type (and its subtypes), we can also constrain it to two or more types. By constraining, we mean a type can take only one out of the passed constraint types [van Rossum et al., 2014]. We specify constraints as a tuple of types:
1class Graph[T: (str, int)]:
2 # the rest of the code
Upper bound or constraints
You cannot define both: constraints and an upper bound!
Remember, you need to specify no fewer than two constraints!
5.7. Types of Variance#
Now that we understand generics, let’s dive into a somewhat advanced aspect: variance. The term variance might sound confusing, as it’s usually associated with the statistical measure of data spread. However, in Python’s type system, as clearly explained in PEP 483 [van Rossum and Levkivskyi, 2014], there are three types of variance. In general, types can be:
Covariant
Contravariant
Invariant
Sounds mysterious? Let us see some real-world examples, quite far apart from the software development world:
Type of Variance |
Plain Explanation |
Real-Life Analogy |
|---|---|---|
Covariance |
You can use something more specific where something more general is expected. |
🐶 Example: A dog is an animal beyond any doubt! |
Contravariance |
You can accept something more general where something more specific is expected. |
👩🏫 Example: Imagine a teacher who can teach any student, regardless of age or subject. |
Invariance |
Only an exact type match works, no substitutions allowed. |
🐕 Example: A dog bowl is made only for dogs. |
5.7.1. Variance in Python Code#
For Python generics, starting from Python 3.12, parameter type variance (e.g., T in our class Graph[T]: definition) is inferred automatically, unless specified explicitly.
Default variance type
The default type of variance in Python generics is invariant, meaning exact match is required. Use covariant=True for immutable containers (read-only) and contravariant=True for consumer types (write-only/callable parameters).