Python's type hints, introduced in PEP 484, turned an entirely dynamic language into one with an optional, gradual static-typing layer. This lesson covers what type hints actually are, how the checker reasons about them, and how professional codebases use them to catch bugs at write-time instead of runtime.
1What Type Hints Actually Are (and Are Not)
PEP 484 introduced type hints as an entirely optional annotation syntax layered on top of Python's existing dynamic typing ā nothing about the runtime execution model changed. When you write def greet(name: str) -> str:, the interpreter stores str in the function's __annotations__ dict and moves on; it does not insert any check that name is actually a string when greet is called.
The actual enforcement comes from a separate static type checker ā mypy, pyright, or pyre ā run as a standalone tool, typically in your editor in real time and again in CI. These tools perform static analysis: they trace how values flow through your code without executing it, and flag any place where an annotated type doesn't match how a value is actually used.
This separation is deliberate and is what makes Python's typing 'gradual': you can annotate one module fully, leave another completely untyped, and both run identically. Teams adopt typing incrementally, starting with public APIs and expanding inward, rather than needing an all-or-nothing rewrite.
def greet(name: str) -> str:
return f"Hello, {name}!"
greet(42) # mypy error: Argument 1 has incompatible type "int"; expected "str"
# runtime: still just prints "Hello, 42!" ā Python itself never checks2Generic Containers and Optional Values
Bare list or dict annotations tell a type checker almost nothing useful ā list[int] versus list[str] is the difference between code that's actually verified and code that merely looks annotated. Since Python 3.9, you can use the built-in generics directly (list[int], dict[str, float]) without importing List/Dict from typing, which was required pre-3.9.
Optional[X], or the equivalent and now-preferred X | None syntax (3.10+), documents that a value can legitimately be absent and forces callers to handle it. This is where type checkers add real value beyond documentation: once you write if user is not None:, mypy performs type narrowing ā inside that branch, it treats user as dict, not dict | None, and would flag any attempt to use .get() on it outside the guard as potentially operating on None.
The discipline this creates is significant: a function that returns dict | None makes it impossible to 'forget' the not-found case without the type checker complaining, closing off an entire class of AttributeError: 'NoneType' object has no attribute bugs that would otherwise only surface at runtime, often days later in production.
def total_score(scores: list[int]) -> int:
return sum(scores)
total_score([90, 85, 77]) # OK
total_score(["90", "85"]) # mypy errormypy treats user as dict, not dict | None
3Protocols: Typing Behavior, Not Ancestry
Python has always favored duck typing ā 'if it walks like a duck and quacks like a duck, treat it as a duck' ā and for years that idiom had no clean way to express itself in the type system, forcing a choice between inheriting from an abstract base class or giving up on static checking entirely. typing.Protocol, from PEP 544, solves this with structural typing: a class satisfies Sized simply by having a __len__ method, with zero explicit relationship to the Protocol class itself.
This matters enormously for library code. A function that accepts a Sized parameter works with list, str, dict, a custom class, or literally any third-party type that happens to implement __len__ ā none of which need to know your Protocol exists, let alone inherit from it. Nominal typing (explicit inheritance, as with ABCs) would force every one of those types to be modified or wrapped just to satisfy the signature.
Protocols are how the standard library itself is typed in many places ā Iterable, Iterator, Sized, and Hashable in typing are all Protocols under the hood ā and they're the idiomatic tool whenever you're typing 'accepts anything with this interface' rather than 'accepts instances of this specific class family'.
def find_user(user_id: int) -> dict | None:
return database.get(user_id)
user = find_user(7)
if user is not None:
print(user["name"]) # mypy is happy: None was narrowed outlist satisfies Sized ā no inheritance needed
4Step-by-Step Breakdown
Every production Python codebase you'll work on professionally uses type hints. Let's learn to read and write them fluently.
A type hint is just an annotation. Python doesn't enforce it at runtime ā a static checker like mypy reads it and flags mismatches before you ship.
Checkpoint: Does calling greet(42) crash at runtime if greet is annotated def greet(name: str) -> str?
- āNo ā Python does not enforce type hints at runtime
- āYes ā Python raises a TypeError immediately
Container types need their contents typed too. list[int] means a list where every element is an int ā not just 'a list'.
Optional[X] (or X | None) says a value might be None. It forces you to handle the None case explicitly before using the value.
For behavior-based typing (duck typing), use Protocol ā it describes 'anything with this method', without requiring inheritance.
Checkpoint: What does typing.Protocol enable that a normal abstract base class does not?
- āStructural typing ā matching by shape, with no explicit inheritance required
- āFaster attribute lookups at runtime
Type hints unlock a whole family of related tools ā next up, Union Types, for expressing 'this could be one of several types.'
Narrow a Real Optional Type. Finish greet(): checking for None first is exactly how you narrow an Optional type.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Type public function signatures first, expand inward gradually
You get the highest signal-to-effort ratio by typing module boundaries and public APIs first ā that's where mismatches cause the most damage and where callers benefit most from editor autocompletion.
Run mypy (or pyright) in CI, not just locally
Type hints without an enforced checker in CI silently rot ā someone eventually breaks a signature and nobody notices until the next person manually opens their editor. `mypy --strict` as a required CI check keeps annotations honest.
Frequent Bugs
Believing a function is 'safe' from type errors simply because it has annotations, without ever actually running a type checker against it.
Type hints are inert without a checker. Install and run mypy or pyright ā in your editor and in CI ā for annotations to provide any real protection.
Real-World Examples
A Typed Repository Interface with Protocol
A service layer needs to accept any data-access object that can fetch a user by ID, without coupling to a specific database implementation (Postgres, in-memory test double, etc.).
from typing import Protocol
class UserRepository(Protocol):
def get_user(self, user_id: int) -> dict | None: ...
def get_display_name(repo: UserRepository, user_id: int) -> str:
user = repo.get_user(user_id)
return user["name"] if user else "Unknown User"