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.

Python code on screen
When your IDE finally knows what's going on ( photo from Unsplash )

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 result

That'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.

Laptop with code
The face you make when someone returns dict[str, Any] ( photo from Unsplash )

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.

Keyboard and typing
One does not simply add type hints to a legacy codebase ( photo from Unsplash )

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-optional

Start 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 safe

No 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. :)