Python Type Hints: A Practical Guide
How to actually use Python type hints beyond the basics — generics, Optional, Protocol, and when they're worth the effort
Type hints are optional in Python, which means a lot of developers skip them entirely or use them inconsistently. Here’s what’s actually worth adopting, and why.
The basics, quickly
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * times
Type hints don’t affect runtime behaviour on their own — Python doesn’t enforce them. Their value comes from tooling: your editor’s autocomplete gets dramatically better, and a type checker like mypy can catch mismatches before you ever run the code.
Optional and Union: handling “might be None”
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
# returns None if not found
...
Optional[dict] is shorthand for dict | None. In modern Python (3.10+), the pipe syntax is preferred and clearer:
def find_user(user_id: int) -> dict | None:
...
This matters more than it looks — a function that can return None but isn’t typed that way is a common source of AttributeError crashes when a caller forgets to check.
Generics: typing containers properly
def get_first(items: list[str]) -> str | None:
return items[0] if items else None
Modern Python lets you use built-in generics directly (list[str], dict[str, int]) rather than importing List/Dict from typing — cleaner, and it’s been the recommended approach since Python 3.9.
Protocol: structural typing without inheritance
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def render(shape: Drawable) -> None:
shape.draw()
Protocol lets you type-hint “anything with a draw() method” without requiring a class to explicitly inherit from a base class — this is Python’s version of structural typing (duck typing, but checkable). Useful when you’re typing code that works with objects from libraries you don’t control, where forcing inheritance isn’t an option.
TypedDict: typing dictionaries with a known shape
from typing import TypedDict
class UserData(TypedDict):
name: str
age: int
def process(user: UserData) -> None:
print(user["name"])
If you’re passing around dictionaries with a consistent, known set of keys (common with JSON API responses before you’ve modeled them as proper classes), TypedDict gives you the type-checking benefit without the overhead of defining a full class.
Where Pydantic fits in
Pydantic goes beyond static type hints — it validates types at runtime, raising real errors when data doesn’t match the declared shape, which plain type hints (checked only by mypy, not enforced by Python itself) don’t do. This is why Pydantic is the standard choice for API request/response models in FastAPI — you need actual runtime validation of untrusted external input, not just editor hints.
Is it worth the effort?
For anything beyond a short script: yes. Type hints pay off fastest on functions other people (or future you) will call without re-reading the implementation — public APIs, shared utility functions, anything with non-obvious inputs. For truly throwaway scripts, skip them; the value comes from code that gets read and called more than once.