Type Hints: Industrial-Grade Python
1 · The lesson
readPython is dynamic. At runtime, def f(x: int) -> str: accepts a list and returns None without any error. The interpreter ignores annotations entirely.
So why write them? Because a static type checker (mypy, pyright, or whatever your editor runs in the background) reads those annotations and tells you, before you release the code, that f is being called with a list. IDE autocomplete becomes accurate. Refactoring becomes safer. And a whole group of bugs, where a None passes through eight function calls and crashes in production, gets caught before the code runs.
This lesson covers what you need to add types to a real codebase: built-in generics, unions, Optional, Callable, TypeVar, Protocol, TypedDict, Literal, NewType, plus how to work with mypy/pyright, and how to add types step by step (gradual typing) so the work stays manageable.
1. Why Use Type Hints: What a Checker Gives You
Annotations give you four things, none of them at runtime:
1. Documentation that stays up to date: the signature tells you the types, and the checker complains if it is wrong.
2. IDE autocomplete and go-to-definition that work on your dynamic code.
3. Refactoring safety: rename a field, and the checker shows you every place that needs updating.
4. Bug detection: unexpected None values, arguments of the wrong type, forgotten return paths.
A concrete example. This short function looks fine:
def first_name(user): return user["name"].split()[0]
Type it, and the checker finds the real bug:
from typing import Optional def first_name(user: Optional[dict]) -> str: return user["name"].split()[0] # ^ error: Value of type "dict[Any, Any] | None" is not indexable (user can be None)
The fix is to handle None and to type the dict more precisely (TypedDict, section 9). You could find these bugs without types. The difference is that the checker finds them on every change, automatically, without you reading the code.
2. Recap: Basic Syntax
You met this in functions:
def greet(name: str, times: int = 1) -> str: return ("Hello, " + name + "!\n") * times count: int = 0 ratio: float = 0.5 done: bool = False names: list[str] = []
Three places annotations appear: function parameters, function return, and variables. The colon syntax is the same in all three. Defaults still work normally: the annotation goes before the =.
3. Built-In Generics (3.9+)
Before Python 3.9, you needed imports from typing:
from typing import List, Dict, Tuple, Set # the old way def fn(xs: List[int]) -> Dict[str, int]: ...
In 3.9+, use the built-in types directly, because you can now use square brackets on them:
def fn(xs: list[int]) -> dict[str, int]: ... coords: tuple[float, float, float] = (1.0, 2.0, 3.0) tags: set[str] = {"alpha", "beta"}
Both still work, but the lowercase form is the standard one. In new code, you should only see List/Dict/Tuple in codebases that must support Python versions older than 3.9.
tuple has two flavours:
point: tuple[int, int] # exactly two ints coords: tuple[int, ...] # a tuple of ints, of any length
The ... (a literal ellipsis) means "a tuple of any length where every item has the same type".
4. Optional[X] and Union: The | Form
Before 3.10:
from typing import Optional, Union def lookup(key: str) -> Optional[int]: ... def parse(x: Union[str, bytes]) -> int: ...
3.10+ has PEP 604 syntax, which lets you write unions with |:
def lookup(key: str) -> int | None: ... def parse(x: str | bytes) -> int: ...
Optional[X] means exactly X | None. Use | in new code, because it reads better and doesn't need an import.
Watch the meaning: Optional[int] (or int | None) means "the value can be int or None." It doesn't mean "the argument is optional". That is arg: int = 0. The two often appear together, but they are not the same thing.
5. Any: The Way Out of Type Checking
Any turns off type checking for a value. Any type can go in, and any type can come out.
from typing import Any def parse_json(s: str) -> Any: # we don't know the shape return json.loads(s)
setup added so this can run · defines json
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) json = _AutoMock('json')
Use it when:
- You're typing a boundary with truly unknown shape (raw JSON,
pickle.load). - You're gradually adding types and haven't gotten to a section yet.
Don't use it as a default, because it silently disables every check. Every Any in your code is a place the type checker can't help you. Prefer object (which forces explicit narrowing) when you can.
6. Callable[...]: Function Signatures as Types
When a parameter is itself a function:
from typing import Callable def apply(fn: Callable[[int, int], int], a: int, b: int) -> int: return fn(a, b) apply(lambda x, y: x + y, 2, 3) # 5
Pattern: Callable[[arg_types], return_type]. The double brackets aren't a typo: the first part is a list of the argument types.
For a no-argument callable returning a string: Callable[[], str]. For "any callable, don't check the signature": Callable[..., int] (literal ellipsis).
7. Generics: TypeVar and PEP 695
A generic function works with any type, but its input and output are linked. first(items: list[T]) -> T says: whatever the list contains, that's what comes out.
Pre-3.12 form: declare a TypeVar first:
from typing import TypeVar T = TypeVar("T") def first(items: list[T]) -> T: return items[0] x = first([1, 2, 3]) # the checker infers T=int, so x is an int y = first(["a", "b"]) # the checker infers T=str, so y is a str
3.12+ form: PEP 695 inline syntax, with no TypeVar import:
def first[T](items: list[T]) -> T: return items[0]
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) T = _AutoMock('T') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from typing import TypeVar except Exception: pass _pm_earlier = [ 'from typing import TypeVar\n\nT = TypeVar("T")\n\ndef first(items: list[T]) -> T:\n return items[0]\n\nx = first([1, 2, 3]) # the checker infers T=int, so x is an int\ny = first(["a", "b"]) # the checker infers T=str, so y is a str', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
It means the same thing, with less code. The [T] after the function name introduces the type parameter for that scope.
Classes work the same way:
# Pre-3.12 from typing import Generic, TypeVar T = TypeVar("T") class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, x: T) -> None: self._items.append(x) def pop(self) -> T: return self._items.pop() # 3.12+ class Stack[T]: def __init__(self) -> None: self._items: list[T] = [] def push(self, x: T) -> None: self._items.append(x) def pop(self) -> T: return self._items.pop()
You can constrain or bound a TypeVar:
from typing import TypeVar Number = TypeVar("Number", int, float) # exactly int or float Comparable = TypeVar("Comparable", bound="SupportsLt") # any subtype of SupportsLt
PEP 695 syntax: def f[T: SupportsLt](x: T) -> T: for the bound form.
8. Protocol: Structural Typing
Python has always used duck typing: if it has .read(), treat it as a file. Protocol brings that into the type system without requiring an inheritance hierarchy.
from typing import Protocol class SupportsClose(Protocol): def close(self) -> None: ... def safely_close(resource: SupportsClose) -> None: resource.close()
Any class with a close(self) -> None method satisfies SupportsClose. You don't need class MyFile(SupportsClose):. The checker looks at which methods a class has, not at which classes it inherits from.
This is the right tool when:
- You're typing a parameter that accepts "anything that behaves like X."
- You can't (or don't want to) edit the third-party class to inherit from your interface.
The standard library already has ready-made protocols: SupportsInt, SupportsFloat, Iterable[T], Iterator[T], Sized, Hashable. Use them when they fit.
9. TypedDict: Dict-Shaped Records
Sometimes the data really is a dict: JSON payloads, network message formats, config data. You don't want to convert to a dataclass. You want type-checked dict access.
from typing import TypedDict class User(TypedDict): id: int name: str email: str def greet(u: User) -> str: return f"Hi {u['name']}" u: User = {"id": 1, "name": "Bob", "email": "s@example.com"} greet(u) # OK greet({"id": 1, "name": "x"}) # checker error: missing 'email'
TypedDict is only used by the static checker. At runtime, User values are plain dicts: there is no isinstance check, no validation and no __init__. Codebases that don't use Pydantic often type their JSON this way.
For partial dicts, declare optional keys:
class User(TypedDict, total=False): # all keys optional id: int name: str class User(TypedDict): # mix required and optional keys (3.11+) id: int # required name: NotRequired[str] # import NotRequired from typing
setup added so this can run · defines TypedDict, NotRequired
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) TypedDict = _AutoMock('TypedDict') NotRequired = _AutoMock('NotRequired')
Compare with @dataclass (dataclasses): the same fields, with different trade-offs:
TypedDict | @dataclass | |
|---|---|---|
| At runtime | A dict | A real instance |
| Construction | {"id": 1, ...} | User(id=1, ...) |
| Validation | None (static only) | __post_init__ if you write it |
| Best for | JSON / wire formats | Domain objects with methods |
10. Literal[...]: A Lighter Enum
When a parameter accepts only a fixed set of string or int values:
from typing import Literal Colour = Literal["red", "green", "blue"] def paint(c: Colour) -> None: ... paint("red") # OK paint("purple") # checker error: not in literal set
It is simpler than a full enum.Enum when the values are already strings or ints in your data. It also works well with discriminated unions, for data like {"kind": "circle", "radius": 1.0} vs {"kind": "square", "side": 2.0}.
11. Final and NewType
Final says "do not reassign":
from typing import Final MAX_RETRIES: Final = 3 MAX_RETRIES = 5 # checker error: cannot assign to Final
This is useful for constants, so the checker reports any accidental reassignment.
NewType creates a distinct type for the static checker. At runtime it is the same value, but the checker treats it as a different type:
from typing import NewType UserId = NewType("UserId", int) OrderId = NewType("OrderId", int) def get_user(uid: UserId) -> dict: ... uid = UserId(42) oid = OrderId(99) get_user(uid) # OK get_user(oid) # checker error: OrderId is not UserId get_user(42) # checker error: plain int is not UserId
This catches a common bug: passing an order_id where a user_id was expected. At runtime, UserId(42) == 42 is True. The difference exists only for the type checker.
12. ClassVar: Class-Level Fields in Dataclasses
In a @dataclass, every annotated attribute becomes an instance field by default. ClassVar stops this and keeps the attribute on the class, not the instance:
from dataclasses import dataclass from typing import ClassVar @dataclass class Connection: host: str port: int default_timeout: ClassVar[int] = 30 # shared across all instances c = Connection("db", 5432) print(c.default_timeout) # 30 (read from the class) Connection.default_timeout = 60 # change for all instances
Without ClassVar, the dataclass would generate default_timeout as an __init__ parameter, which isn't what you want for a shared default.
13. Forward References
Sometimes you need to refer to a type before it is defined, for example in recursive types or in two classes that refer to each other. Use a string:
class Tree: def __init__(self, value: int, children: "list[Tree]" = None) -> None: self.value = value self.children = children or []
Or, more cleanly, turn on PEP 563 lazy annotations for the whole file:
from __future__ import annotations # at the top of the file class Tree: def __init__(self, value: int, children: list[Tree] | None = None) -> None: self.value = value self.children = children or []
from __future__ import annotations stores every annotation as a string. Python evaluates them only when something asks for them (the checker reads them without running them). As a result, forward references work without quotes, and you can write list[int] / X | None in annotations on versions before 3.10. The cost: anything that reads annotations at runtime (Pydantic, FastAPI, attrs) must call get_type_hints() to evaluate them. In a typical app, you will not notice this cost.
14. Running mypy / pyright
The checker is a separate tool. Install it once:
pip install mypy mypy your_package/
A concrete bug it catches:
# users.py def first_user_name(users: list[dict]) -> str: return users[0].get("name") # .get returns Optional[str]!
$ mypy users.py users.py:2: error: Incompatible return value type (got "Optional[str]", expected "str")
The bug: dict.get returns None when the key is missing. The function claims to return str. The fix is either users[0]["name"] (raises on missing) or change the return type to str | None (caller handles it).
pyright (and its VS Code version, Pylance) works the same way, but it is faster and is the default in many editors.
Strictness levels:
- Default: lenient. It misses some problems, but it is good for older codebases.
mypy --strict: turns on the strict checks. It is the same as enabling many individual flags.# type: ignore[error-code]on a single line: turns off one error on that line only. Always include the error code so it doesn't suppress unrelated errors that appear later.
result = library_call() # type: ignore[no-untyped-call]
setup added so this can run · defines library_call
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def library_call(*_a, **_kw): print('-> library_call() called') return _AutoMock('library_call()')
Use # type: ignore like Any: sparingly, with intent, and with a comment explaining why.
15. Annotations at Runtime
The interpreter stores annotations in __annotations__:
def fn(x: int, y: str = "") -> bool: ... print(fn.__annotations__) # {'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'bool'>}
With from __future__ import annotations, those become strings. Use typing.get_type_hints() to resolve them properly:
from typing import get_type_hints print(get_type_hints(fn)) # same dict, with strings evaluated
setup added so this can run · re-runs 3 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) fn = _AutoMock('fn') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from typing import List, Dict, Tuple, Set except Exception: pass _pm_earlier = [ 'from typing import List, Dict, Tuple, Set # the old way\ndef fn(xs: List[int]) -> Dict[str, int]: ...', 'def fn(xs: list[int]) -> dict[str, int]: ...\ncoords: tuple[float, float, float] = (1.0, 2.0, 3.0)\ntags: set[str] = {"alpha", "beta"}', 'def fn(x: int, y: str = "") -> bool: ...\n\nprint(fn.__annotations__)\n# {\'x\': <class \'int\'>, \'y\': <class \'str\'>, \'return\': <class \'bool\'>}', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
This is how Pydantic and FastAPI work. They read your annotations when your class or function is defined, and build validators, serialisers and OpenAPI schemas from them. Your type hints then define how the data is checked at runtime:
from pydantic import BaseModel class User(BaseModel): id: int name: str email: str User(id="1", name="Bob", email="x@y.z") # Pydantic turns "1" into 1 and checks the types
These are the same annotations the type checker reads, but now the library also checks them at runtime.
16. The Gradual-Typing Mindset
You don't have to type the whole codebase. Mypy and pyright are designed for gradual typing: some code has annotations and some doesn't, and you add types one file at a time. A practical order:
1. Start with the public API of each module: the functions other code calls. Wrong types here cause the most problems in other code.
2. Then boundary code: anything that reads JSON, parses input, or talks to the network. This is where bad data gets in.
3. Then internal helpers, when they cause problems or when you change them.
4. Add mypy to CI in lenient mode first, then make it stricter one flag at a time.
# type: ignore and Any let you skip checks where you must. Use them to keep CI passing while you work on the rest. Don't fill the codebase with Any to silence the checker. If you do, you have annotations, but the checker can't check anything.
Common Mistakes
1. Dict[str, int] in 3.9+ code
It works, but it is the old style. Lowercase dict[str, int] is the standard form from 3.9 onward. Same for List, Tuple, Set, Type, FrozenSet.
2. Misreading Optional[X]
Optional[X] is X | None: the value can be None. It is not "this parameter is optional." Optional parameters (with defaults) and optional values (that can be None) are unrelated concepts that share a word.
def fn(x: Optional[int] = None) -> None: ... # has a default AND can be None def fn(x: int = 0) -> None: ... # has a default, but can NOT be None def fn(x: int | None) -> None: ... # required, but can be None
setup added so this can run · re-runs 2 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) Optional = _AutoMock('Optional') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from typing import Optional from typing import Optional, Union except Exception: pass _pm_earlier = [ 'from typing import Optional\n\ndef first_name(user: Optional[dict]) -> str:\n return user["name"].split()[0]\n # ^ error: Value of type "dict[Any, Any] | None" is not indexable (user can be None)', 'from typing import Optional, Union\n\ndef lookup(key: str) -> Optional[int]: ...\ndef parse(x: Union[str, bytes]) -> int: ...', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
3. Forgetting Self (3.11+) for fluent builders
from typing import Self # 3.11+ class Builder: def with_name(self, n: str) -> Self: self.name = n return self
Self correctly types subclass returns. With -> "Builder", a subclass's .with_name() would be typed as returning the base class, so the checker loses the subclass type when you chain calls. Self is the right annotation for __enter__, copy(), and any builder method.
4. Any everywhere
def process(data: Any) -> Any: ... # what was the point of types?
setup added so this can run · defines Any
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) Any = _AutoMock('Any')
Any turns off checking wherever it is used. Replace with object (forces narrowing), a Protocol (structural), TypeVar (generic), or a specific union as appropriate. Each Any is a place the checker can't help.
5. Treating List[int] (capital L) as deprecated
It is deprecated, but only softly: the docs mark it deprecated since 3.9, with no removal date, and it still works. The capital List is the older spelling. Don't break old code over it, but do prefer lowercase in new code.
6. Believing annotations enforce anything at runtime
def double(x: int) -> int: return x * 2 print(double("hi")) # 'hihi' (no TypeError, no warning)
The interpreter does nothing with annotations. Static checkers, IDEs, and runtime libraries (Pydantic, FastAPI, attrs) use them, but the interpreter ignores them. If you need runtime validation, use one of those libraries or add manual isinstance checks at the boundary.
🎯 Your Turn: Type an Untyped Function
Below is a fairly general CSV-row parser. It works, but it has no types, and it is not clear what it accepts and returns. Add full type hints.
Requirements:
- The
transformargument is a callable taking astrand returning a value of some typeT. Use aTypeVar. - Missing values (empty strings in the row) should be
Nonein the output, not run throughtransform. UseOptional/| None. - Type
transformas aProtocolwith a__call__method, so any callable with the right signature satisfies it. - The function returns a
listof dicts: one dict per row, keyed by column name. Also declare aTypedDictfor the id/name/email rows, and use it to type the parsed records.
def parse_csv(rows, columns, transform): """Parse CSV rows into dicts; transform each non-empty cell.""" results = [] for row in rows: record = {} for col, value in zip(columns, row): if value == "": record[col] = None else: record[col] = transform(value) results.append(record) return results # Example use: this should pass the type checker when you finish rows = [["1", "Bob", ""], ["2", "Ada", "ada@x.y"]] columns = ["id", "name", "email"] records = parse_csv(rows, columns, lambda s: s.strip()) # records is now [{"id": "1", "name": "Bob", "email": None}, # {"id": "2", "name": "Ada", "email": "ada@x.y"}]
Hint 1: TypeVar for the transform
T = TypeVar("T"). The transform takes a str and returns T. Each call to parse_csv infers a different T based on the callable you pass.
Hint 2: Protocol for the callable
T_co = TypeVar("T_co", covariant=True) and class StringTransform(Protocol[T_co]): with a __call__(self, value: str) -> T_co: method (mypy wants a covariant type variable when the protocol only returns it). Then transform: StringTransform[T]. (You can also write it as Callable[[str], T]. Both work, but the Protocol states more clearly that it is a callable that takes a string.)
Hint 3: TypedDict can't have dynamic keys
A realTypedDict has fixed keys at class-definition time. For the exercise, declare a TypedDict for your specific columns (id/name/email). In a fully generic version you'd return list[dict[str, T | None]], but the point of this exercise is to see TypedDict in action.
Show full solution
from typing import Protocol, TypeVar, TypedDict, Optional T = TypeVar("T") T_co = TypeVar("T_co", covariant=True) # a protocol that only returns T needs a covariant one class StringTransform(Protocol[T_co]): """Any callable that takes a str and returns a T.""" def __call__(self, value: str) -> T_co: ... class Record(TypedDict): """A parsed CSV row with our known columns. None means the cell was empty.""" id: Optional[str] name: Optional[str] email: Optional[str] def parse_csv( rows: list[list[str]], columns: list[str], transform: StringTransform[T], ) -> list[dict[str, T | None]]: """Parse CSV rows into dicts; transform each non-empty cell. Empty strings become None (not passed through `transform`). """ results: list[dict[str, T | None]] = [] for row in rows: record: dict[str, T | None] = {} for col, value in zip(columns, row): record[col] = None if value == "" else transform(value) results.append(record) return results # Demo: the type checker infers T from the lambda rows = [["1", "Bob", ""], ["2", "Ada", "ada@x.y"]] columns = ["id", "name", "email"] # T = str, because the transform takes a str and returns a str trimmed = parse_csv(rows, columns, lambda s: s.strip()) print(trimmed) # [{'id': '1', 'name': 'Bob', 'email': None}, {'id': '2', 'name': 'Ada', 'email': 'ada@x.y'}] # T = int: the transform returns int, so parse_csv returns list[dict[str, int | None]] numeric = parse_csv([["1", "2"], ["3", ""]], ["a", "b"], lambda s: int(s)) print(numeric) # [{'a': 1, 'b': 2}, {'a': 3, 'b': None}] # Give the id/name/email rows their TypedDict type def to_record(row: dict[str, str | None]) -> Record: return {"id": row["id"], "name": row["name"], "email": row["email"]} people: list[Record] = [to_record(r) for r in trimmed] print(people[1]["email"]) # ada@x.y # people[1]["emial"] makes mypy report: TypedDict "Record" has no key "emial"
What you ended up with:
- A
TypeVarthat links input and output types: the checker tracksTacross the call. - A
Protocolthat captures the single-method "callable from str" interface without forcing inheritance. Optional/| Noneat the cell level: an empty string becomesNone, which is different from a transformed value.- A
TypedDictcapturing the specific shape of one parsed row, available to callers as a documented record type.
Run mypy on this file: zero errors, and any caller that passes a callable returning the wrong type gets caught immediately. (A wrong column name isn't caught on trimmed, because each row there is a plain dict[str, ...]. On people, which is a list[Record], mypy catches it. That is what the Record TypedDict is for.)
The fully-generic version (no Record TypedDict, just dict[str, T | None]) is more flexible but less self-documenting. In practice, choose the one that matches the real shape of the data: fixed schema → TypedDict, changing columns → dict.
What You Learned
- Annotations are static documentation enforced by tools, not the interpreter. They are useful because of mypy/pyright, IDE help, and runtime libraries like Pydantic/FastAPI.
- Built-in generics (3.9+):
list[int],dict[str, int],tuple[int, str]. Lowercase is the standard form. X | None(3.10+) replacesOptional[X], andA | BreplacesUnion[A, B].Callable[[args], ret]types function-typed parameters.- Generics:
TypeVar(pre-3.12) or PEP 695def f[T](...)(3.12+) link inputs to outputs. Protocolfor structural typing: duck typing that the checker can verify.TypedDictfor dict-shaped data with fixed keys,Literalfor fixed value sets,NewTypefor distinct semantic types over a common runtime type, andFinalfor "do not reassign".from __future__ import annotationslets you write forward references without quotes, and useX | Nonein annotations before 3.10.# type: ignore[code]andAnyturn off checking. A few are fine, but too many make the checker useless.- Gradual typing: type the public API and the boundaries first, and add types to the internal code over time.
Next: Performance: measuring before optimising, where Python is fast, where it isn't, and the toolkit (timeit, cProfile, dis) for finding out.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.