Python · lesson 29 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Python Type Hints and Static Typing
Annotate Python code and run mypy on it: unions, narrowing, generics, Protocol, TypedDict and --strict, with the real errors each one catches.
By SkillByExample · Updated
Type hints annotate what a variable, parameter or return value should hold. Python itself ignores them at runtime; a static checker such as mypy reads them and reports mistakes before the code runs. Every example below is checked with mypy 2.4.0 and run with Python 3.13.
Hints don’t run — checkers do
The same file, run by Python and then checked by mypy.
def greet(name: str, times: int = 1) -> str:
return ", ".join([f"Hello, {name}"] * times)
print(greet("Ana", 2))
print(greet(42))
print(greet.__annotations__)
$ python greet.py
Hello, Ana, Hello, Ana
Hello, 42
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
$ mypy greet.py
greet.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Python happily called greet(42) — the f-string turned the int into text. The annotations were stored in __annotations__ and otherwise ignored. mypy read them, found the mismatch on line 5, and named the rule (arg-type) so it can be looked up or silenced deliberately.
Unions, None and narrowing
X | None says a value may be missing, and mypy then insists you handle that case.
def find_user(user_id: int) -> str | None:
return {1: "ana", 2: "bo"}.get(user_id)
def shout(user_id: int) -> str:
name = find_user(user_id)
return name.upper()
def shout_safe(user_id: int) -> str:
name = find_user(user_id)
if name is None:
return "NOBODY"
reveal_type(name)
return name.upper()
print(shout_safe(3))
$ mypy narrowing.py
narrowing.py:6: error: Item "None" of "str | None" has no attribute "upper" [union-attr]
narrowing.py:12: note: Revealed type is "str"
Found 1 error in 1 file (checked 1 source file)
$ python narrowing.py
NOBODY
shout would crash with AttributeError for an unknown id, and mypy caught it without running anything. In shout_safe, the is None check narrows the type: reveal_type (understood by the checker, and built in at runtime since 3.11 — it also prints to stderr when run) shows that after the check name is plain str. isinstance checks narrow the same way. Before 3.10 this was spelled Optional[str] or Union[str, None].
Collections and callables
Annotate parameters with the most general type you need: Iterable for “anything I can loop over”, Sequence when you index, Mapping for read-only dicts.
from collections.abc import Callable, Iterable, Mapping
def normalise(items: Iterable[str]) -> list[str]:
return [item.strip().lower() for item in items]
def total(prices: Mapping[str, float]) -> float:
return sum(prices.values())
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
print(normalise((" A ", "b ")), normalise(s for s in ["C"]))
print(total({"tea": 2.5, "cake": 4.0}))
print(apply(lambda x, y: x * y, 3, 4))
print(apply(max, 3, 4))
ages: dict[str, int] = {"ana": 31}
ages["bo"] = "twenty"
print(apply(len, 3, 4))
$ mypy collections_hints.py
collections_hints.py:18: error: Incompatible types in assignment (expression has type "str", target has type "int") [assignment]
collections_hints.py:19: error: Argument 1 to "apply" has incompatible type "Callable[[Sized], int]"; expected "Callable[[int, int], int]" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
Because normalise accepts Iterable[str], a tuple and a generator both type-check — a list[str] parameter would have rejected them. Callable[[int, int], int] describes a function value: max fits, but len takes one argument, so it doesn’t. The dict[str, int] annotation made storing a string a type error.
Generics: TypeVar and the 3.12 syntax
A type variable links the type that goes in to the type that comes out.
from collections.abc import Sequence
from typing import TypeVar
T = TypeVar("T")
def first(items: Sequence[T]) -> T | None:
return items[0] if items else None
def last[U](items: Sequence[U]) -> U | None: # Python 3.12+ syntax
return items[-1] if items else None
n = first([1, 2, 3])
s = last(["a", "b"])
reveal_type(n)
reveal_type(s)
length: int = len(first(["x"]))
$ mypy generics.py
generics.py:14: note: Revealed type is "int | None"
generics.py:15: note: Revealed type is "str | None"
generics.py:16: error: Argument 1 to "len" has incompatible type "Sized | None"; expected "Sized" [arg-type]
Found 1 error in 1 file (checked 1 source file)
mypy substituted the caller’s element type: a list of ints gives int | None, a list of strs gives str | None — with -> Any both would be unchecked. The error on line 16 is real: first can return None, and len(None) would crash. (There mypy solved T from what len expects, hence Sized | None in the message.)
Generic classes
A class parameterised by a type variable lets Stack[int] and Stack[str] be distinct types.
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if not self._items:
raise IndexError("pop from empty stack")
return self._items.pop()
numbers: Stack[int] = Stack()
numbers.push(1)
numbers.push(2)
print(numbers.pop() + 10)
numbers.push("three")
$ mypy stack.py
stack.py:17: error: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
$ python stack.py
12
pop() is known to return int, so + 10 is fine; pushing a string into a Stack[int] is flagged. At runtime the push of "three" would have succeeded silently. Before 3.12, write class Stack(Generic[T]) with a module-level T = TypeVar("T").
Protocol: duck typing the checker understands
A Protocol describes the methods a type must have; no inheritance is required.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "()"
class Label:
def draw(self) -> str:
return "[label]"
class Sound:
def play(self) -> str:
return "beep"
def render(shapes: list[Drawable]) -> str:
return " ".join(s.draw() for s in shapes)
print(render([Circle(), Label()]))
print(isinstance(Circle(), Drawable), isinstance(Sound(), Drawable))
render([Circle(), Sound()])
$ mypy protocols.py
protocols.py:24: error: List item 1 has incompatible type "Sound"; expected "Drawable" [list-item]
Found 1 error in 1 file (checked 1 source file)
$ python protocols.py
() [label]
True False
Traceback (most recent call last):
File "/home/user/py-demo/typing/protocols.py", line 24, in <module>
render([Circle(), Sound()])
~~~~~~^^^^^^^^^^^^^^^^^^^^^
File "/home/user/py-demo/typing/protocols.py", line 20, in render
return " ".join(s.draw() for s in shapes)
~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/py-demo/typing/protocols.py", line 20, in <genexpr>
return " ".join(s.draw() for s in shapes)
^^^^^^
AttributeError: 'Sound' object has no attribute 'draw'
Neither Circle nor Label mentions Drawable, yet both satisfy it because they have a matching draw method. Sound has no draw, so mypy rejects the list item — and running the file shows the crash it predicted, on the last line. @runtime_checkable additionally allows isinstance checks, which only test that the method exists, not its signature.
TypedDict: shapes for plain dicts
When data stays a dict — JSON, for instance — TypedDict declares its keys and value types.
from typing import NotRequired, TypedDict
class User(TypedDict):
id: int
name: str
role: NotRequired[str]
def describe(user: User) -> str:
return f"{user['name']} ({user.get('role', 'member')})"
print(describe({"id": 1, "name": "Ana"}))
print(describe({"id": 2, "name": "Bo", "role": "admin"}))
describe({"id": "3", "name": "Cy"})
describe({"id": 4})
$ mypy typed_dict.py
typed_dict.py:13: error: Incompatible types (expression has type "str", TypedDict item "id" has type "int") [typeddict-item]
typed_dict.py:14: error: Missing key "name" for TypedDict "User" [typeddict-item]
Found 2 errors in 1 file (checked 1 source file)
Each wrong dict literal got a precise error, while role could be omitted because it’s NotRequired. At runtime a TypedDict is an ordinary dict — nothing here would be caught without the checker. For validation of data arriving at runtime, use pydantic (see security).
--strict
By default mypy skips functions that have no annotations at all. --strict turns on the optional checks, including requiring annotations everywhere.
def add(a, b):
return a + b
def double(x: int) -> int:
return add(x, x)
print(double(4))
$ mypy loose.py
Success: no issues found in 1 source file
$ mypy --strict loose.py
loose.py:1: error: Function is missing a type annotation [no-untyped-def]
loose.py:5: error: Returning Any from function declared to return "int" [no-any-return]
loose.py:5: error: Call to untyped function "add" in typed context [no-untyped-call]
Found 3 errors in 1 file (checked 1 source file)
The default run passed because add is unannotated, so mypy treats it as returning Any and checks nothing that flows through it. --strict exposes that hole: double claims to return int but really returns whatever add returns. Strict mode is the right default for new code; in a large existing codebase, turn checks on gradually in pyproject.toml under [tool.mypy].
Practice
1. Annotate def mean(values): return sum(values) / len(values) so that it accepts any sequence of floats, and check it with mypy --strict.
Solution
from collections.abc import Sequence
def mean(values: Sequence[float]) -> float:
return sum(values) / len(values)
print(mean([1, 2, 3.5]), mean((4.0, 6.0)))
$ mypy --strict mean.py
Success: no issues found in 1 source file
$ python mean.py
2.1666666666666665 5.0
Sequence is needed (not Iterable) because len requires a sized collection. Passing ints is accepted: for type checking, int is compatible wherever float is expected.
2. Fix shout from the narrowing example so mypy reports no errors, without changing find_user.
Solution
def find_user(user_id: int) -> str | None:
return {1: "ana", 2: "bo"}.get(user_id)
def shout(user_id: int) -> str:
name = find_user(user_id)
if name is None:
raise KeyError(user_id)
return name.upper()
print(shout(1))
$ mypy shout.py
Success: no issues found in 1 source file
$ python shout.py
ANA
Raising in the None branch narrows name to str for the rest of the function, just like returning early.
3. Write a Protocol called SupportsClose with a close() -> None method and a function close_all(items: list[SupportsClose]) -> int that closes each and returns the count. Show that in-memory file objects (io.StringIO, io.BytesIO) satisfy it.
Solution
import io
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def close_all(items: list[SupportsClose]) -> int:
for item in items:
item.close()
return len(items)
buffers: list[SupportsClose] = [io.StringIO("a"), io.BytesIO(b"b")]
print(close_all(buffers))
$ mypy close_all.py
Success: no issues found in 1 source file
$ python close_all.py
2
StringIO and BytesIO never heard of SupportsClose, but their close methods match its signature, so they qualify structurally.
4. Make Stack from the generic-class example also have a peek() -> T | None method, then use reveal_type to confirm mypy’s view of Stack[str]().peek().
Solution
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def peek(self) -> T | None:
return self._items[-1] if self._items else None
words: Stack[str] = Stack()
words.push("hi")
reveal_type(words.peek())
$ mypy peek.py
peek.py:13: note: Revealed type is "str | None"
Success: no issues found in 1 source file