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.

TopicsType HintsmypyBest Practices