PythonMastery
advanced 18 min read · lesson 6 of 9 in Python Advanced

Type Hints: Industrial-Grade Python

1 · The lesson

read

Python 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:

python
def first_name(user):
    return user["name"].split()[0]

Type it, and the checker finds the real bug:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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 |:

python
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.

python
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:

python
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:

python
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:

python
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:

python
# 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:

python
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.

python
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.

python
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:

python
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 runtimeA dictA real instance
Construction{"id": 1, ...}User(id=1, ...)
ValidationNone (static only)__post_init__ if you write it
Best forJSON / wire formatsDomain objects with methods

10. Literal[...]: A Lighter Enum

When a parameter accepts only a fixed set of string or int values:

python
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":

python
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:

python
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:

python
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:

python
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:

python
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:

bash
pip install mypy
mypy your_package/

A concrete bug it catches:

python
# users.py
def first_user_name(users: list[dict]) -> str:
    return users[0].get("name")                 # .get returns Optional[str]!
python
$ 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.
python
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__:

python
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:

python
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:

python
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.

python
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

python
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

python
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

python
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 transform argument is a callable taking a str and returning a value of some type T. Use a TypeVar.

  • Missing values (empty strings in the row) should be None in the output, not run through transform. Use Optional / | None.

  • Type transform as a Protocol with a __call__ method, so any callable with the right signature satisfies it.

  • The function returns a list of dicts: one dict per row, keyed by column name. Also declare a TypedDict for the id/name/email rows, and use it to type the parsed records.

python
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 real TypedDict 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
python
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 TypeVar that links input and output types: the checker tracks T across the call.
  • A Protocol that captures the single-method "callable from str" interface without forcing inheritance.
  • Optional / | None at the cell level: an empty string becomes None, which is different from a transformed value.
  • A TypedDict capturing 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+) replaces Optional[X], and A | B replaces Union[A, B].
  • Callable[[args], ret] types function-typed parameters.
  • Generics: TypeVar (pre-3.12) or PEP 695 def f[T](...) (3.12+) link inputs to outputs.
  • Protocol for structural typing: duck typing that the checker can verify.
  • TypedDict for dict-shaped data with fixed keys, Literal for fixed value sets, NewType for distinct semantic types over a common runtime type, and Final for "do not reassign".
  • from __future__ import annotations lets you write forward references without quotes, and use X | None in annotations before 3.10.
  • # type: ignore[code] and Any turn 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.in

Short 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.