Python’s support for static typing gradually improves with each new release of Python. The core features were in place in Python 3.5. Since then, there’ve been many tweaks and improvements to the type hinting system. This evolution continues in Python 3.12, which, in particular, simplifies the typing of generics.
In this tutorial, you’ll:
- Use type variables in Python to annotate generic classes and functions
- Explore the new syntax for type hinting type variables
- Model inheritance with the new
@overridedecorator - Annotate
**kwargsmore precisely with typed dictionaries
This won’t be an introduction to using type hints in Python. If you want to review the background that you’ll need for this tutorial, then have a look at Python Type Checking.
You’ll find many other new features, improvements, and optimizations in Python 3.12. The most relevant ones include the following:
- Ever better error messages
- Support for the Linux
perfprofiler - More powerful f-strings
- Support for subinterpreters
Go ahead and check out what’s new in the changelog for more details on these and other features or listen to our comprehensive podcast episode.
Free Bonus: Click here to download your sample code for a sneak peek at Python 3.12, coming in October 2023.
Recap Type Variable Syntax Before Python 3.12
Type variables have been a part of Python’s static typing system since its introduction in Python 3.5. PEP 484 introduced type hints to the language, and type variables and generics play an important role in that document. In this section, you’ll dive into how you’ve used type variables so far. This’ll give you the necessary background to appreciate the new syntax that you’ll learn about later.
A generic type is a type parametrized by another type. Typical examples include a list of integers and a tuple consisting of a float, a string, and another float. You use square brackets to parametrize generics in Python. You can write the two examples above as list[int] and tuple[float, str, float], respectively.
In addition to using built-in generic types, you can define your own generic classes. In the following example, you implement a generic queue based on deque in the standard library:
# generic_queue.py
from collections import deque
from typing import Generic, TypeVar
T = TypeVar("T")
class Queue(Generic[T]):
def __init__(self) -> None:
self.elements: deque[T] = deque()
def push(self, element: T) -> None:
self.elements.append(element)
def pop(self) -> T:
return self.elements.popleft()
This is a first-in, first-out (FIFO) queue. It represents the kind of lines that you’ll find yourself in at stores, where the first person into the queue is also the first one to leave the queue. Before looking closer at the code, and in particular at the type hints, play a little with the class:
>>> from generic_queue import Queue
>>> queue = Queue[int]()
>>> queue.push(3)
>>> queue.push(12)
>>> queue.elements
deque([3, 12])
>>> queue.pop()
3
You can use .push() to add elements to the queue and .pop() to remove elements from the queue. Note that when you called the Queue() constructor, you included [int]. This isn’t necessary, but it tells the type checker that you expect the queue to only contain integer elements.
Normally, using square brackets like you did in Queue[int]() isn’t valid Python syntax. You can use square brackets with Queue, however, because you defined Queue as a generic class by inheriting from Generic. How does the rest of your class use this int parameter?
To answer that question, you need to look at T, which is a type variable. A type variable is a special variable that can stand in for any type. However, during type checking, the type of T will be fixed.
In your Queue[int] example, T will be int in all annotations on the class. You could also instantiate Queue[str], where T would represent str everywhere. This would then be a queue with string elements.
If you look back at the source code of Queue, then you’ll see that .pop() returns an object of type T. In your special integer queue, the static type checker will make sure that .pop() returns an integer.
Speaking of static type checkers, how do you actually check the types in your code? Type annotations are mostly ignored during runtime. Instead, you need to install a separate type checker and run it explicitly on your code.
Note: In this tutorial, you’ll use Pyright as your type checker. You can install Pyright from PyPI using pip:
$ python -m pip install pyright
If you’re using Visual Studio Code, then you can use Pyright inside the editor through the Pylance extension. You may need to activate it by setting the Python › Analysis: Type Checking Mode option in your settings.
If you install Pyright, then you can use it to type check your code:
$ pyright generic_queue.py
0 errors, 0 warnings, 0 informations
To see an example of the kinds of errors that Pyright can detect, add the following lines to your generic queue implementation:
# generic_queue.py
# ...
queue = Queue[int]()
queue.push("three")
queue.push(12)
print(queue.pop() + "twelve")
Here, you instantiate an integer queue, but then you push a string element to the front of the queue. While the code looks a bit troubling, it runs and produces a result:
$ python generic_queue.py
threetwelve
The string "three" that you pushed onto the queue pops off and combines with "twelve" and then prints to the console. Now, ask Pyright to have a look at your code:
$ pyright generic_queue.py
generic_queue.py
generic_queue.py:19:12 - error: Argument of type "Literal['three']" cannot
be assigned to parameter "element" of type "int" in function "push"
"Literal['three']" is incompatible with "int" (reportGeneralTypeIssues)
generic_queue.py:21:7 - error: Operator "+" not supported for types "int"
and "Literal['twelve']" (reportGeneralTypeIssues)
2 errors, 0 warnings, 0 informations
While the inconsistent types happen to not crash your code, Pyright warns you that you’re doing something unintended. The output from Pyright can feel a bit verbose. Note that it describes two errors: first, that you’re pushing "three" to the integer queue and then that you’re adding an element from your integer queue to a string.
Note: Although you’re using Pyright in this tutorial, you can use other type checkers and see similar results as well. There are several static type checkers available for Python, including mypy, pytype, and Pyre.
Not all type checkers have implemented the new features. At the time of writing, mypy doesn’t support the new type variable syntax. However, the mypy team has an overview where they track support for it.
Using type variables in your type annotations is more complex than using simple types. However, they’re also very powerful when you need to enforce types on collections. Without type variables, you’d need to do something like the following to define typed queues:
from collections import deque
class IntegerQueue:
def __init__(self) -> None:
self.elements: deque[int] = deque()
def push(self, element: int) -> None:
self.elements.append(element)
def pop(self) -> int:
return self.elements.popleft()
class StringQueue:
def __init__(self) -> None:
self.elements: deque[str] = deque()
def push(self, element: str) -> None:
self.elements.append(element)
def pop(self) -> str:
return self.elements.popleft()
The only difference between IntegerQueue and StringQueue is their type hints. The code that runs at runtime is identical. This kind of code doesn’t scale well because you’d still need a new class if you wanted a queue of Booleans, for example, and it’s hard to read and hard to maintain. Using type variables is a more efficient and elegant solution.
One of the improvements coming in Python 3.12 is an improved and simplified syntax for type variables. In the next section, you’ll explore this more deeply.
Explore Python 3.12’s New Syntax for Typing Generics
There are improvements to Python’s handling of static typing in each new release. These tend to fall into one of two broad categories:
-
New features: The capabilities of the typing system are constantly growing. More precise typing of
**kwargsis one such capability that you’ll take a closer look at later in the tutorial. These features are usually implemented in thetypingstandard library.Adding a feature to a library instead of to the core language has two advantages. The feature becomes available for users to experiment with without changing Python’s syntax. In addition, the core developers can backport the feature to older versions of Python through the
typing-extensionsthird-party library. -
Improved syntax: As static typing features mature, users push for a simpler syntax. In earlier versions of Python, you needed to import
Listfromtypingto annotate a list. However, since Python 3.9, you can use the built-inlistclass for type hints without needing any imports. Likewise, Python 3.10 added syntax for describing type unions to the language.Changing syntax like this is a much bigger deal than adding a new feature to
typing. It also takes longer to roll out because it only works on the new versions of Python where the syntax is implemented. The developers can’t backport a new feature to older versions of Python.
PEP 695 introduces a new syntax for type variables. Similar to earlier improvements, the new syntax that’s available in Python 3.12 avoids doing imports from typing and makes type variables part of regular Python syntax.
In the coming sections, you’ll explore the new type variable syntax and see some examples of how you can use it in your own code.
Generic Classes and Functions
You’ve seen how you use TypeVar to define type variables. In Python 3.12, you don’t need to explicitly declare type variables when you’re defining generic classes. Instead, you can use the following syntax:
# generic_queue.py
from collections import deque
class Queue[T]:
def __init__(self) -> None:
self.elements: deque[T] = deque()
def push(self, element: T) -> None:
self.elements.append(element)
def pop(self) -> T:
return self.elements.popleft()
The syntax Queue[T] implies that you’re defining a generic class parametrized by T. Note that this implicitly defines T as a type variable that you can refer to within the class definition.
In addition to the cleaner syntax, the fact that you don’t need to import Generic or TypeVar or explicitly define T makes your code cleaner and more readable.
Pyright already supports the new syntax. However, when you’re using it, you need to explicitly tell Pyright that you’re using Python 3.12 syntax:
$ pyright --pythonversion 3.12 generic_queue.py
0 errors, 0 warnings, 0 informations
You can provoke the type checker similarly to earlier if you want to see the kind of errors that it can report.
So far, you’ve looked at generic classes. You can also define generic functions. These are functions that can handle several different types in their parameters.
As an example, you’ll implement push_and_pop() which can push an element to the end of a generic list and pop one off the front of it. First, you’ll implement it with the old syntax for type variables:
# list_helpers.py
from typing import TypeVar
T = TypeVar("T")
def push_and_pop(elements: list[T], element: T) -> T:
elements.append(element)
return elements.pop(0)
As earlier, you declare that T is a type variable. Then you use T as part of your annotations of push_and_pop(). In particular, you make sure that element matches the type of the elements already in the list.
You can experiment with the function to see that it allows you to use a list as a dynamic queue where an element pops off each time you insert a new one:
>>> from list_helpers import push_and_pop
>>> elements = [28, 1]
>>> push_and_pop(elements, 23)
28
>>> push_and_pop(elements, 10)
1
>>> elements
[23, 10]
You start with a list with two elements. When you insert 23 at the back of the list, then you’re also removing the first element, 28.
In Python 3.12, you can use the new syntax for such generic functions as well. Like you would for the generic class syntax, you declare the type variable inside square brackets:
# list_helpers.py
def push_and_pop[T](elements: list[T], element: T) -> T:
elements.append(element)
return elements.pop(0)
Again, note that you don’t need to declare the type variable any longer, apart from listing it within the square brackets.
This code is equivalent to the one above. There’s one significant difference, though. In the earlier example, T is defined in the global scope and is available outside the definition of push_and_pop(). In the latter code block, T is only available in annotations within the function.
Note: The scoping of type variables changes with the new syntax. In fact, the scope of type variables is different from any of the existing scopes in Python. The technical details of the new scope can be overwhelming. In practice, though, the new scoping rules are intuitive and won’t cause any unexpected behavior.
Consult PEP 695 for more information.
In the examples that you’ve explored so far, the type variables could take on any type. This is often the case with collections that can contain any type of object. Type variables can also be useful in cases where the type is more constrained. You’ll look more closely at this in the next section.
Constrained and Bounded Type Variables
Sometimes you have a function that can take arguments of several—but not all—different types. Type variables support this through constraints and bounds:
- A constrained type variable takes on one of a fixed set of possible types.
- A bounded type variable takes on the type of the boundary type or one of its subtypes.
You’ll investigate both constrained and bounded type variables in this section and learn how the new syntax supports them.
As an example, say that you’re wrapping the concatenation operator (+) in a function to make combining strings more explicit:
# concatenation.py
def concatenate(first: str, second: str) -> str:
return first + second
This function adds two strings together to form a new string. Your motivation for adding concatenate() is that it names the operation properly. You can use it to combine two strings as follows:
>>> from concatenation import concatenate
>>> concatenate("twenty", "twentythree")
'twentytwentythree'
In the type hints, you’ve noted that concatenate() is meant to work on strings. Technically, there are many objects in Python that you could add together. However, you wouldn’t necessarily call the operation concatenation. Therefore, your function doesn’t semantically support numbers.
However, there’s another type where + represents concatenation: bytes objects. You can also use concatenate() on them. For example, you can combine the following bytes literals:
>>> from concatenation import concatenate
>>> concatenate(b"twenty", b"twentythree")
b'twentytwentythree'
The only visible difference from strings is the b prefix indicating that each quoted element is a bytes object. You’d like the type hints to include bytes in addition to str.
You can do this by defining a constrained type variable. In the old syntax, this looked as follows:
# concatenation.py
from typing import TypeVar
T = TypeVar("T", str, bytes)
def concatenate(first: T, second: T) -> T:
return first + second
In the declaration of T, you constrain T such that only str and bytes are valid types. If you try to concatenate two numbers, then the type checker will warn you, but the code will run. This T can only be str or bytes, and no other types are valid.
Note: If you’re adding constraints to a type variable, then you must include at least two types. It doesn’t make sense to constrain to only one type, since you’d use that type directly in that case.
The new syntax also supports constraints. You can add type constraints in a literal tuple:
# concatenation.py
def concatenate[T: (str, bytes)](first: T, second: T) -> T:
return first + second
As earlier, you declare T by adding it to the function definition inside square brackets. Additionally, you note that T must be either str or bytes by specifying those types in a tuple literal separated from T by a colon.
A related use case concerns bounded type variables. A bounded type variable is also limited by a specific type—its boundary type. In contrast to constrained type variables, bounded type variables can materialize as subtypes of their boundary type. For example, a type variable bounded by str can be represented by str or any subclass of str.
In the following example, you implement inspect(), which provides a simple description of a string. This function is meant to help with debugging, so it returns the original string, such that you can insert inspect() in the middle of the original processing.
You also define Words as a subclass of str. This is the start of a word-oriented string type where you only override how to calculate the length of a string:
# inspect_string.py
class Words(str):
def __len__(self):
return len(self.split())
def inspect[S: str](text: S) -> S:
print(f"'{text.upper()}' has length {len(text)}")
return text
The annotation [S: str] says that inspect() is a generic function parametrized by the type variable S. Furthermore, S is bound by str. In practice, this means that the text argument must be a string or a subclass of str. For example, both of the following uses are valid:
>>> from inspect_string import inspect, Words
>>> inspect("Hello, World!")
'HELLO, WORLD!' has length 13
'Hello, World!'
>>> inspect(Words("Hello, World!"))
'HELLO, WORLD!' has length 2
'Hello, World!'
Both the string and the Words subclass are valid arguments to inspect() since you’ve annotated text with a type variable bound to str.
You could’ve defined inspect() as a regular function and used a plain text: str type hint to say the same. You need to use the type variable in order to connect the type of the text argument to the return type of inspect().
You can use constrained and bounded type variables when you define generic classes as well. To do so, you use the same syntax, with the type variable separated by a colon from the constraint or bound. To sum up, compare the syntax of a free, constrained, and bounded type variable:
>>> def free[T](argument: T) -> T: ...
>>> def constrained[T: (str, bytes)](argument: T) -> T: ...
>>> def bounded[T: str](argument: T) -> T: ...
In these examples, T is a type variable:
- In
free(),Tcan be any type. - In
constrained(),Tcan only be eitherstrorbytes. - In
bounded(),Tcan bestror any subclass ofstr.
In addition to generic classes and functions, there’s a third way to use type variables. Namely, you can use them in generic type aliases. You’ll learn more about those in the next section.
Type Aliases
Sometimes, you want to use a different name or alias for a type. This could be because the type is cumbersome to describe. For example, you could be working with data represented as a list of tuples, with each tuple consisting of a string and an integer. In that case, you could define an alias:
ListOfTuples = list[tuple[str, int]]
Another reason for using an alias is to be more descriptive about your data structures. Maybe your list of tuples actually represents a deck of cards. In that case, you can use the type alias to say so:
CardDeck = list[tuple[str, int]]
Originally, type aliases were defined as simple assignments. PEP 613 introduced explicit type aliases to make them less ambigous.
Before Python 3.12, you needed to import TypeAlias from typing to use explicit type aliases:
from typing import TypeAlias
CardDeck: TypeAlias = list[tuple[str, int]]