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
@override
decorator - Annotate
**kwargs
more 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
perf
profiler - 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
**kwargs
is one such capability that you’ll take a closer look at later in the tutorial. These features are usually implemented in thetyping
standard 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-extensions
third-party library. -
Improved syntax: As static typing features mature, users push for a simpler syntax. In earlier versions of Python, you needed to import
List
fromtyping
to annotate a list. However, since Python 3.9, you can use the built-inlist
class 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()
,T
can be any type. - In
constrained()
,T
can only be eitherstr
orbytes
. - In
bounded()
,T
can bestr
or 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]]
Using TypeAlias
allows the type checker to infer that CardDeck
is indeed a type alias. Without this annotation, that wasn’t always clear.
In Python 3.12, you can use a powerful new syntax to define type aliases:
type CardDeck = list[tuple[str, int]]
One advantage to using type
like this is that you don’t need to make any imports. You use type aliases as drop-in replacements for the more complex type descriptions that they alias:
# deck.py
import random
type CardDeck = list[tuple[str, int]]
def shuffle(deck: CardDeck) -> CardDeck:
return random.sample(deck, k=len(deck))
Here, shuffle()
returns a new deck with the original cards shuffled into a random order. You use the CardDeck
alias to annotate both the deck
argument and the return value of shuffle()
.
You can also define generic type aliases. These are type aliases that are parametrized by some other type. With the traditional syntax, you’ll do something like this:
from typing import TypeAlias, TypeVar
T = TypeVar("T")
Ordered: TypeAlias = list[T] | tuple[T, ...]
In this case, Ordered
is a generic type that you can represent as either a list or a tuple. In Python 3.12, you can simplify this definition as follows:
type Ordered[T] = list[T] | tuple[T, ...]
This combines the type
syntax for defining type aliases with the square brackets that you used earlier to define generics. Together, they give you generic type aliases.
There are two points to note about the new type
syntax. First of all, it uses type
as a soft keyword. Keywords are identifiers that are reserved by the language and can’t be used variables. Python has more than thirty such reserved words, including def
, class
, and return
.
A soft keyword is a keyword that’s only reserved in certain contexts. Normally, type
refers to the built-in type()
function. However, in type alias assignments, it’s recognized as a keyword.
Second, type aliases defined with type
are only usable as type hints. Since the old-style type aliases are simple assignments, you can sometimes use them for runtime checks. For example, you can use isinstance()
as follows:
>>> from typing import TypeAlias
>>> number: TypeAlias = int | float
>>> isinstance(3.12, number)
True
>>> isinstance("Python", number)
False
You define number
as a type alias representing either an integer or a floating point number. You can then use number
directly when using isinstance()
to check the type of different objects.
You can’t do the same with type aliases defined with type
:
>>> type number = int | float
>>> isinstance(3.12, number)
Traceback (most recent call last):
...
TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union
Using this new type alias in isinstance()
causes a TypeError
. This shouldn’t be a big issue. Type aliases are mainly meant for type annotations, which you want to keep separate from any runtime type enforcement that you need to do.
As you’ve seen, Python 3.12 simplifies the syntax for working with generic classes, functions, and type aliases. It makes working with generics less cumbersome and more intuitive. Unfortunately, this is new syntax, so it won’t be backported to older versions of Python.
The development, discussion, and implementation of PEP 695 has been a massive undertaking. Core developer Jelle Zijlstra has been instrumental in this effort. He’s also written an interesting report with detail and insight about the implementation.
In the rest of this tutorial, you’ll investigate a couple of new static typing features that also join the language in Python 3.12.
Model Inheritance With @override
PEP 698 introduces a new decorator named @override
. You can use @override
to mark methods in a class that are overriding methods from the parent class. This means that @override
isn’t directly involved in specifying a type. Still, it can be a useful addition to your static typing checks because it helps you catch certain kinds of bugs.
The inspiration for this decorator partly comes from similar features in other languages, including C++ and Java. In Python, checking that a method does in fact override its parent method happens when you run a static type checker. Using @override
won’t have any effect during runtime.
The third-party library overrides
, which you can install with pip
, provides functionality similar to @override
but does the checking while your program runs. Depending on your use case, you may find that this library better suits your project.
Inheritance in Python
To see an example of @override
in action, you’ll implement a basic object-oriented quiz program. Along the way, you’ll practice using inheritance in Python. In the first iteration, you won’t use @override
. Consider the following Question
class:
# quiz.py
from dataclasses import dataclass
@dataclass
class Question:
question: str
answer: str
def ask_question(self) -> bool:
answer = input(f"\n{self.question} ")
return answer == self.answer
For simplicity, you define Question
as a data class. It’ll have two string attributes, .question
and .answer
. Additionally, .ask_question()
will ask the question and compare a user’s answer to the correct one. The method returns True
if the user answers correctly and False
if they don’t.
To run a quiz, you’ll add a couple of questions and have the program ask them in a loop:
# quiz.py
import random
from dataclasses import dataclass
# ...
questions = [
Question("Who created Python?", "Guido van Rossum"),
Question("What's a PEP?", "A Python Enhancement Proposal"),
]
score = 0
for question in random.sample(questions, k=len(questions)):
if question.ask_question():
score += 1
print("Yes, that's correct!")
else:
print(f"No, the answer is '{question.answer}'")
print(f"\nYou got {score} out of {len(questions)} correct")
You use random.sample()
to shuffle the questions so that your program asks them in a random order. Since .ask_question()
returns True
or False
depending on whether the answer is correct, you can use an if
block to give some feedback to your user and keep track of their score. Running the quiz will look something like this:
$ python quiz.py
What's a PEP? A Python Enhancement Proposal
Yes, that's correct!
Who created Python? Uncle Barry
No, the answer is 'Guido van Rossum'
You got 1 out of 2 correct
The example code is intentionally basic, as you’ll soon use it to explore the new @override
decorator. If you’re interested in creating a more solid quiz program, then give Build a Quiz Application With Python a spin.
You’ll introduce one improvement to this quiz program, though. To make the game less of a spelling bee, you’ll add support for multiple-choice questions. One way to do this is to add a new class that inherits from Question
:
# quiz.py
import random
from dataclasses import dataclass
from string import ascii_lowercase
# ...
@dataclass
class MultipleChoiceQuestion(Question):
distractors: list[str]
def ask_question(self) -> bool:
print(f"\n{self.question}")
alternatives = random.sample(
self.distractors + [self.answer], k=len(self.distractors) + 1
)
labeled_alternatives = dict(zip(ascii_lowercase, alternatives))
for label, alternative in labeled_alternatives.items():
print(f" {label}) {alternative}", end="")
answer = input("\n\nChoice? ")
return labeled_alternatives.get(answer) == self.answer
# ...
The new class, MultipleChoiceQuestion
, inherits from Question
and overrides .ask_question()
to handle showing answer alternatives to the user. To create a multiple-choice question, you need to provide a list of distractors, or wrong answers, in addition to the question and answer. Your code shuffles all the answer alternatives and labels them each with a letter that the user can use when answering.
To use your new class, you update one of your questions:
# quiz.py
# ...
questions = [
Question("Who created Python?", "Guido van Rossum"),
MultipleChoiceQuestion(
"What's a PEP?",
"A Python Enhancement Proposal",
distractors=[
"A Pretty Exciting Policy",
"A Preciously Evolved Python",
"A Potentially Epic Prize",
],
),
]
# ...
Here, you’ve changed the PEP question to a MultipleChoiceQuestion
by adding three distractors. You can see your new code in action:
$ python quiz.py
Who created Python? Guido van Rossum
Yes, that's correct!
What's a PEP?
a) A Potentially Epic Prize b) A Python Enhancement Proposal
c) A Preciously Evolved Python d) A Pretty Exciting Policy
Choice? b
Yes, that's correct!
You got 2 out of 2 correct
Now, change gears to see how @override
can improve your code. First, there’s nothing wrong with your code so far. However, if you’re not careful, there are a few potential bugs that could sneak into your code.
For example, if there’s a typo in the name when you define .ask_question()
in MultipleChoiceQuestion
, then your code will call .ask_question()
in the Question
class instead. Try to rename MultipleChoiceQuestion.ask_question()
to something like .ask_questoin()
to see what happens! The game still runs, but you lose the special handling of multiple-choice questions.
Similarly, you may realize that you can simplify the method name by changing Question.ask_question()
to Question.ask()
. Your editor may help you with this refactoring. However, if you miss renaming .any_question()
in the subclass, then the game will break again. As above, you won’t get any error messages. You’ll only see that multiple-choice questions stop behaving properly.
If you use @override
, then you’ll have some protection against these kinds of issues.
Safe Refactoring With @override
You’ll now add the @override
decorator to your quiz program and experiment with how it can help keep you safe from certain bugs. Python 3.12 adds @override
to the typing
standard library module. As with many typing features, you may also import it from typing_extensions
on earlier Python versions.
Start by marking MultipleChoiceQuestion.ask_question()
as an override:
# quiz.py
import random
from dataclasses import dataclass
from string import ascii_lowercase
from typing import override
# ...
@dataclass
class MultipleChoiceQuestion(Question):
distractors: list[str]
@override
def ask_question(self) -> bool:
# ...
# ...
You need to make two updates to your code. First, you import @override
from typing
. Next, you mark .ask_question()
as an override by applying the decorator to the method. This won’t have any effect on what your program does when you run it.
You can use Pyright to check that you’ve properly typed your file:
$ pyright --pythonversion 3.12 quiz.py
0 errors, 0 warnings, 0 informations
Currently, quiz.py
is in tip-top shape! Next, you’ll introduce some bugs in your code to see how Pyright will warn you. First, you’d want to refactor the method name by changing .ask_question()
to .ask()
. Do the update, but only in the parent Question
class and in the supporting code:
# quiz.py
# ...
@dataclass
class Question:
question: str
answer: str
def ask(self) -> bool:
answer = input(f"\n{self.question} ")
return answer == self.answer
# ...
score = 0
for question in random.sample(questions, k=len(questions)):
if question.ask():
score += 1
print("Yes, that's correct!")
else:
print(f"No, the answer is '{question.answer}'")
print(f"\nYou got {score} out of {len(questions)} correct")
Notably, you don’t update MultipleChoiceQuestion.ask_question()
. Many editors can help you with renaming methods like this, but in big codebases, it’s still possible to miss updating a method name. However, Pyright can now point out what’s gone wrong:
$ pyright --pythonversion 3.12 quiz.py
quiz.py
quiz.py:21:9 - error: Method "ask_question" is marked as override, but no
base method of same name is present (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
The message says that you’ve marked .ask_question()
as an override, but the method isn’t overriding any method in a parent class. Because you changed the name to .ask()
in Question
, you’d need to change the name to .ask()
in MultipleChoiceQuestion
as well.
You’ll get similar protection against misspelling a method name. Say that when you go back to your code to update the method name, you end up with the following:
# quiz.py
# ...
@dataclass
class MultipleChoiceQuestion(Question):
distractors: list[str]
@override
def askk(self) -> bool:
# ...
# ...
Note that .askk()
is misspelled. For your program, this will have the same effect as not renaming the method. Your multiple-choice questions will not behave properly. If you run Pyright, then you’ll also get a similar message:
$ pyright --pythonversion 3.12 quiz.py
quiz.py
quiz.py:39:9 - error: Method "askk" is marked as override, but no base
method of same name is present (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Hopefully, these messages will alert you to the correct issue. If you’re working on a project that uses inheritance, then applying @override
can give you more confidence that your code works correctly.
Using @override
is optional. Your type checker shouldn’t enforce these hints by default. Consider what happens if you fix MultipleChoiceQuestion
but remove @override
:
# quiz.py
# ...
@dataclass
class MultipleChoiceQuestion(Question):
distractors: list[str]
def ask(self) -> bool:
# ...
# ...
By removing @override
, you’re leaving the type checker in the dark about whether .ask()
is meant to override its parent method or not. However, Pyright won’t complain:
$ pyright --pythonversion 3.12 quiz.py
0 errors, 0 warnings, 0 informations
If you want to make sure that you’re using @override
consistently, then you can add a diagnostic flag to Pyright’s configuration. One way to do this is to specify the following in a file named pyproject.toml
:
[tool.pyright]
reportImplicitOverride = true
You can now rerun Pyright:
$ pyright --pythonversion 3.12 quiz.py
quiz.py
quiz.py:38:9 - error: Method "ask" is not marked as override but is
overriding a method in class "Question" (reportImplicitOverride)
1 error, 0 warnings, 0 informations
By enabling reportImplicitOverride
, you’re telling Pyright that you intend to use @override
everywhere that it’s applicable. You’re therefore getting a message saying that you should annotate .ask()
since it’s overriding Question.ask()
.
This offers you the choice to gradually introduce @override
in projects where that makes sense and to enforce the use of @override
when you find that convenient.
Annotate **kwargs
More Precisely
In Python, you can use so-called **kwargs
to define functions that take a variable number of keyword arguments. The values that you pass into such functions are gathered up in a dictionary that you can process. The following example lists options that are passed:
>>> def show_options(program_name, **kwargs):
... print(program_name.upper())
... for option, value in kwargs.items():
... print(f"{option:<15} {value}")
...
>>> show_options("logger", line_width=80, level="INFO", propagate=False)
LOGGER
line_width 80
level INFO
propagate False
Your first argument, "logger"
, is passed into program_name
, while your three keyword arguments are collected into kwargs
. The important part of the syntax is the double asterisk symbol (**
). The name kwargs
is just a convention, and sometimes it makes sense to use a more descriptive name. You could rewrite show_options()
as follows:
>>> def show_options(program_name, **options):
... print(program_name.upper())
... for option, value in options.items():
... print(f"{option:<15} {value}")
...
>>> show_options("logger", line_width=80, level="INFO", propagate=False)
LOGGER
line_width 80
level INFO
propagate False
Your code is functionally the same as before, but you’ve renamed **kwargs
to the more explicit **options
.
Before Python 3.12, typing support for **kwargs
has been limited because you haven’t been able to describe all types precisely. You’ve been able to add a type hint to **kwargs
, just like for any other parameter:
def show_options(program_name: str, **kwargs: str) -> None:
# ...
However, the annotation **kwargs: str
is interpreted as saying that every keyword argument in kwargs
has type str
, or string. There’s no way of specifying that a specific keyword argument has type int
while another has type bool
. Instead, all keyword arguments are annotated with the same type.
Note: Since the value of kwargs
is a dictionary with all the keyword arguments, you may be tempted to use something like **kwargs: dict[str, str]
as a type annotation. However, that would imply that each keyword argument would be a dictionary, and kwargs
would be a nested dictionary of dictionaries.
In a function like show_options()
where many different types can be passed, you’d often end up using a type union like **kwargs: str | int | bool
or, more likely, the Any
type, as in **kwargs: Any
.
You can now use TypedDict
, or typed dictionary, to add more precise type hints to **kwargs
. This is specified in PEP 692.
A TypedDict
is used together with a static type checker to enforce the names of dictionary keys and the types of dictionary values. You can, for example, define the following:
>>> from typing import TypedDict
>>> class Options(TypedDict):
... line_width: int
... level: str
... propagate: bool
...
This defines a typed dictionary with three keys:
"line_width"
, whose value must be anint
"level"
, whose value must be astr
"propagate"
, whose value must bebool
Up until now, Python has used these typed dictionaries to give more information about regular dictionaries. However, you can now start using them as type hints for **kwargs
as well, as long as you wrap them inside Unpack
:
>>> from typing import Unpack
>>> def show_options(program_name: str, **kwargs: Unpack[Options]) -> None:
... print(program_name.upper())
... for option, value in kwargs.items():
... print(f"{option:<15} {value}")
...
Recall that annotations for **kwargs
normally apply to each keyword argument. You use Unpack
to say that the typed dictionary provides different type hints for different keyword arguments. If you use **kwargs: Options
, then you’re saying that each keyword argument should be an Options
dictionary.
Note: Python 3.11 introduced Unpack
to work with TypeVarTuple
. Its use now extends to typed dictionaries as well.
Adding these kinds of type hints to your **kwargs
allows your static type checker to discover two different kinds of mistakes:
- You’re passing the wrong type for a given argument, like specifying
level
as an integer. - You’re using an unsupported argument. That is, you’re passing in anything not defined in
Options
.
The latter point is one potential downside to using a typed dictionary to annotate **kwargs
: you need to list all possible arguments.
By default, all fields in a typed dictionary are required. In this example, that means that you must pass in line_width
, level
, and propagate
and can’t leave any of them out. That’s not usually what you intend when using **kwargs
. You can switch this so that none of the keys are required by specifying total=False
when you define the typed dictionary:
>>> from typing import TypedDict
>>> class Options(TypedDict, total=False):
... line_width: int
... level: str
... propagate: bool
...
In this case, all the arguments are optional. For more fine-grain control, you can also use Required
and NotRequired
. For example, to denote that only level
is required, you can do the following:
>>> from typing import Required, TypedDict
>>> class Options(TypedDict, total=False):
... line_width: int
... level: Required[str]
... propagate: bool
...
Since total=False
, the keys are optional by default. However, annotating level
with Required
means that this argument won’t be optional.
Using TypedDict
gives you a tool for annotating **kwargs
. However, if you have a function that takes a variable number of keyword arguments, but you can specify the arguments in a typed dictionary, then you might be better off specifying them as regular keyword arguments.
For example, you can define show_options()
as follows instead:
>>> def show_options(
... program_name: str,
... *,
... level: str,
... line_width: int | None = None,
... propagate: bool | None = None,
... ) -> None:
... options = {
... "line_width": line_width,
... "level": level,
... "propagate": propagate,
... }
... print(program_name.upper())
... for option, value in options.items():
... if value is not None:
... print(f"{option:<15} {value}")
...
This implementation of show_options()
is more verbose than earlier. In particular, it uses None
as a marker for optional values, and it ends up repeating each parameter name three times.
The advantage of this version over the previous ones is that it explicitly lists the parameters. This makes the function easier to use since you don’t need to look elsewhere for which arguments you should pass in.
At the same time, the added complexity of gathering the arguments into a dictionary manually may not be worth it. In your own code, you should make a judgment call based on what’s most important to you.
Conclusion
Python 3.12 continues the tradition of improving the ecosystem for static typing. The biggest change in this release is the new syntax for generic classes, functions, and type aliases. At the same time, there are a couple of new typing-related features that are interesting as well.
In this tutorial, you’ve explored:
- Type variables in Python and how they annotate generic classes and functions
- The new syntax for using type variables
- The new
@override
decorator that you can use to model inheritance - Typed dictionaries and how you can use them to annotate
**kwargs
There are many other improvements and new features coming to Python 3.12 in addition to the static typing enhancements. Have a look at What’s New in the changelog to keep up with all the changes.
Free Bonus: Click here to download your sample code for a sneak peek at Python 3.12, coming in October 2023.