Beyond the basicsChapter 89 of 114
Type Hints
Say what types you expect, for readers and for tools.
Annotating
Hints go after a colon for parameters, and after an arrow for the return:
def greet(name: str) -> str:
return f"Hello, {name}"
print(greet("Ada"))Output
Hello, Ada
Variables can be annotated too, though it is rarely needed when the value is obvious:
count: int = 0
names: list[str] = ["Ada"]
print(count, names)Output
0 ['Ada']
Python does not enforce them
This is the part that surprises people. Hints are documentation that tools can read; the interpreter ignores them:
def greet(name: str) -> str:
return f"Hello, {name}"
print(greet(42))Output
Hello, 42
No error. To catch that you run a checker such as mypy or pyright, usually in your editor and in CI.
Why bother
- Your editor can autocomplete and catch typos before you run anything
- A reader knows what to pass without reading the body
- A checker finds a whole class of bug without writing a test
The cost is some noise. Most projects annotate function signatures and leave local variables bare.
Collections
Since 3.9 the builtin names work directly, and no import is needed:
def totals(scores: dict[str, int]) -> list[int]:
return sorted(scores.values())
print(totals({"ada": 9, "grace": 7}))Output
[7, 9]
| Hint | Means |
|---|---|
list[int] | a list of integers |
dict[str, int] | string keys, integer values |
tuple[int, int] | exactly two integers |
tuple[int, ...] | any number of integers |
set[str] | a set of strings |
Maybe missing
X | None says the value may be absent. It is so common it has a name, Optional[X], though the pipe form is now preferred:
def find(names: list[str], target: str) -> int | None:
for index, name in enumerate(names):
if name == target:
return index
return None
print(find(["ada", "grace"], "grace"))
print(find(["ada"], "alan"))Output
1 None
def add(item: str, target: list[str] | None = None) -> list[str]:
if target is None:
target = []
target.append(item)
return target
print(add("a"))Output
['a']
Several possible types
def double(value: int | float | str) -> int | float | str:
return value * 2
print(double(5), double("ab"))Output
10 abab
An alias keeps a long hint readable:
type Number = int | float
def half(value: Number) -> Number:
return value / 2
print(half(9))Output
4.5
Hinting your own classes
class Dog:
def __init__(self, name: str) -> None:
self.name = name
def rename(self, name: str) -> "Dog":
self.name = name
return self
def loudest(dogs: list[Dog]) -> Dog:
return max(dogs, key=lambda dog: len(dog.name))
print(loudest([Dog("Rex"), Dog("Bartholomew")]).name)Output
Bartholomew
__init__ returns nothing, so it is annotated -> None.
Checking at runtime, when you need to
Hints are readable from code, which is how libraries like pydantic validate data automatically:
def greet(name: str) -> str:
return f"Hello, {name}"
print(greet.__annotations__)Output
{'name': <class 'str'>, 'return': <class 'str'>}Adopt them gradually
You do not need to annotate everything at once. Start with the functions other code calls most, where the payoff is largest, and leave the rest until you touch it.
Test yourself
2 questionsWhat does Python do with type hints at runtime?
Show the answer
Nothing; it ignores them — A checker such as mypy or pyright reads them, usually in your editor and in CI.
How should you hint a parameter whose default is None?
Show the answer
int | None — Writing 'x: int = None' is a contradiction, and a checker will point it out.
Async Basics
Do other work while waiting, without threads.