Python

Python Type Hints👨‍💻

Type hints stopped being optional for serious Python around 2020. Every major framework ships with them (FastAPI, Pydantic, SQLAlchemy 2.0, Django 4+), every decent CI pipeline runs a type checker, and every library worth using publishes py.typed stubs. If you're still writing untyped Python, you're leaving bugs on the table that a five-second pyright run would catch.

The core system is straightforward: you annotate function parameters and return types, and a static checker (pyright, mypy) verifies consistency without ever running your code. Python 3.12 overhauled the syntax with PEP 695, making generics and type aliases dramatically cleaner. The old TypeVar / TypeAlias ceremony is still everywhere in existing code, so you need to know both — but new code should use the modern syntax.

This page covers everything from basic annotations to advanced patterns like TypedDict for API responses, Protocol for structural subtyping, type guards for narrowing, and the @overload decorator for functions with multiple signatures.

Key Takeaways

  • 1Type hints are checked statically by tools like pyright and mypy — they have zero runtime cost and are completely ignored by the Python interpreter
  • 2Python 3.12+ introduced PEP 695 syntax: `def f[T](x: T) -> T` replaces the old `T = TypeVar('T')` boilerplate, and `type Alias = ...` replaces `TypeAlias`
  • 3Use `int | str | None` (3.10+) instead of `Union[int, str]` and `Optional[int]` — the pipe syntax is clearer and requires no imports
  • 4TypedDict gives you typed dictionaries with known keys — use it for JSON payloads, API responses, and config objects instead of bare `dict[str, Any]`
  • 5Protocol enables structural subtyping (static duck typing): a class satisfies a Protocol if it has the right methods, no inheritance required
  • 6Type guards (`TypeGuard`, `TypeIs`) and `isinstance` checks let the type checker narrow union types in conditional branches

Master python type hints

Take the Python Architecture course with hands-on lessons and challenges.

Examples

Function signatures and the pipe union syntax

python

The pipe syntax (3.10+) replaces Union and Optional. Use `X | None` instead of `Optional[X]` — it reads better and requires no typing import. The type checker narrows the union after a None check.

PEP 695 generics — the modern way (3.12+)

python

PEP 695 eliminates the TypeVar and Generic boilerplate. Type parameters go in brackets after the function or class name. Bounded parameters use `T: Bound`. The `type` soft keyword creates type aliases that support forward references natively.

TypedDict for API responses

python

TypedDict defines dictionaries with specific keys and value types. Use Required/NotRequired (3.11+) for mixed optionality. Combine with Literal for string enums. This is the standard pattern for typing JSON payloads from REST APIs.

Protocol and the @overload decorator

python

Protocol enables structural subtyping: any class with a matching `render()` method satisfies Renderable, no inheritance needed. The @overload decorator tells the type checker that different input types produce different return types — the implementation signature is not visible to callers.

Type guards for narrowing union types

python

TypeGuard narrows a type in the True branch of a conditional. TypeIs (3.13+) is stricter but narrows in both branches. For simple isinstance checks, the type checker narrows automatically. Pattern matching (match/case) also narrows types.

The old TypeVar syntax — still everywhere in existing code

python

You'll encounter TypeVar everywhere in libraries, typeshed stubs, and pre-3.12 codebases. Know the migration path: `T = TypeVar('T')` becomes `[T]` in the function/class signature, `TypeAlias` becomes the `type` statement, and `Generic[T]` is no longer needed as a base class.

Common Mistakes

Mistake:

Using `dict[str, Any]` as a catch-all for structured data — this kills type safety for every downstream access

Fix:

Use TypedDict for dictionaries with known keys: `class Config(TypedDict): host: str; port: int`. The type checker validates every key access. Reserve `dict[str, Any]` for genuinely dynamic data.

Mistake:

Writing `Optional[X]` or `Union[X, None]` instead of `X | None` — the old syntax is verbose and requires imports

Fix:

Use the pipe syntax on Python 3.10+: `def find(id: int) -> User | None`. It reads like English and requires no `from typing import` anything. Only fall back to `Optional` if you support 3.9.

Mistake:

Assuming type hints are enforced at runtime — then being confused when `greet(42)` doesn't raise an error despite `name: str`

Fix:

Type hints are metadata only. Python never checks them at runtime. You must run a static checker (pyright, mypy) in your CI pipeline or editor. For runtime validation, use Pydantic or beartype.

Mistake:

Creating `T = TypeVar('T')` at module scope in new Python 3.12+ code instead of using the PEP 695 syntax

Fix:

Use `def f[T](x: T) -> T` and `class Box[T]` in 3.12+ code. The PEP 695 syntax is scoped (no module-level pollution), more readable, and what the language is converging on. Reserve old-style TypeVar for libraries that still support 3.11.

Best Practices

  • Use pyright over mypy for new projects — it's faster, has better inference, and catches more edge cases. VS Code's Pylance extension runs pyright under the hood, giving you real-time feedback.
  • Start with `# pyright: strict` or mypy `--strict` on new codebases. Retrofitting strict typing onto a large untyped codebase is painful; starting strict from day one is nearly free.
  • Annotate function signatures, not local variables. Pyright and mypy infer locals just fine — `x = 5` doesn't need `: int`. Only annotate locals when the inferred type is wrong or too broad (e.g., `items: list[str] = []`).
  • Use `Sequence`, `Mapping`, and `Iterable` from `collections.abc` in function parameters instead of concrete `list`, `dict`, `set`. This follows the Liskov substitution principle — your function should accept any iterable, not demand a list.
  • Combine TypedDict with Literal types for discriminated unions in API responses — `status: Literal['success', 'error']` lets the type checker narrow the entire dict shape based on a single field.
  • Ship a `py.typed` marker in your library packages and add inline type annotations instead of separate `.pyi` stubs. This ensures consumers get type checking out of the box without hunting for third-party stub packages.

Summary

Python type hints are static annotations checked by tools like pyright and mypy, with zero runtime overhead. Python 3.12 introduced PEP 695, which replaces TypeVar boilerplate with clean `def f[T]()` syntax and the `type` statement for aliases. For structured data, use TypedDict over bare dicts. For interfaces, use Protocol for structural subtyping. Combine Literal types, type guards, and @overload to model complex function signatures precisely. Run a type checker in CI — type hints without enforcement are just comments.

Practice Python with hands-on challenges

Learn python type hints hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master Python with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.