I avoided type hints for years. Thought they were Java cosplay for people who missed writing boilerplate. Then I joined a project where every function signature looked like a crime scene — six parameters, all dicts, zero documentation. Three hours of debugging later I was a convert.
Type hints don't make your code safer at runtime. That's not the point. They make your code readable. They make autocomplete actually work. They make you stop guessing what the hell that third argument is supposed to be.
The Basics You Actually Need
Start with function signatures. That's it. Don't annotate every variable — that way lies madness.
from typing import Optional
def get_user(user_id: int) -> Optional[dict]:
result = db.query("SELECT * FROM users WHERE id = ?", user_id)
if not result:
return None
return resultThat's 90% of the value right there. Function parameters and return types. Done.
What About Complex Types?
When you start passing lists of dicts of lists, type hints save your life.
from typing import TypedDict
class UserInfo(TypedDict):
name: str
email: str
active: bool
def process_users(users: list[UserInfo]) -> dict[str, int]:
return {u["name"]: len(u["email"]) for u in users}TypedDict is underrated. Use it instead of slapping dict[str, Any] on everything. Your future self will thank you.
Union and Pipe Syntax
Python 3.10 gave us the pipe operator for unions. Use it.
# Old and ugly
from typing import Union
def parse(value: Union[str, int]) -> float:
...
# New and clean
def parse(value: str | int) -> float:
...Same thing, less noise. If you're on 3.10+, there's zero reason to import Union anymore.
When Not to Use Type Hints
Don't annotate local variables inside functions unless the type is genuinely unclear.
# This is pointless
name: str = "Davide"
count: int = 0
# This is useful
results: dict[str, list[tuple[str, float]]] = defaultdict(list)The first one tells you nothing you didn't already know. The second one saves you from staring at a complex type for ten seconds every time you read it.
Mypy as a CI Check
Run mypy in CI. Not locally — nobody remembers. Put it in the pipeline, set --strict if you're brave, or --ignore-missing-imports if you're sane.
# In your CI config
- name: Type check
run: mypy src/ --ignore-missing-imports --no-strict-optionalStart with loose settings and tighten over time. Running mypy --strict on day one of adding hints to a legacy project is a one-way ticket to despair.
The One Pattern I Use Everywhere
Protocol classes. If you're checking isinstance() or catching AttributeError, write a Protocol instead.
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def cleanup(resource: Closeable) -> None:
resource.close() # mypy knows this is safeNo inheritance. No base classes. Just duck typing that actually works with your type checker. This is the cleanest pattern in Python's type system and almost nobody uses it.
Type hints are not about safety. They're about communication. Add them to public APIs first, internal helpers second, local variables never. Run mypy in CI. Use TypedDict and Protocol. Skip everything else until you actually need it. :)