Type Hints That Actually Help: A Practical Deep Dive into Python's typing
Learn how to use Python's type hints for real: modern union and generic syntax, TypedDict, Protocol, overloads, type aliases, and the pitfalls that trip people up — with runnable examples.
Type hints are one of the most misunderstood parts of modern Python. Some developers sprinkle a few : int annotations on function parameters and call it a day; others avoid them entirely, convinced they add clutter without paying it back. Both camps miss the point. Used deliberately, type hints turn your editor into a bug-catcher, make refactoring far less scary, and serve as documentation that can never drift out of date because tools check it for you.
Crucially, Python itself does not enforce type hints at runtime. They are annotations, not constraints. A separate type checker — mypy, pyright/Pylance, or ty — reads them and flags mismatches before your code ever runs. This article is a practical tour of the hints that earn their keep, using the modern syntax available in Python 3.10 through 3.13.
The basics, and why "no enforcement" matters
An annotation attaches a type to a name. Here is a function with fully annotated parameters and return value:
def repeat(name: str, times: int = 1) -> str:
return (name + " ") * times
print(repeat("hi", 3)) # "hi hi hi "
Run this with a wrong type and Python happily executes it:
print(repeat("hi", "3")) # TypeError at runtime, not from the hint
The hint said times should be an int, but Python never checks. What catches the mistake is a type checker. Install one and run it over your file:
pip install mypy
mypy your_file.py
# error: Argument 2 to "repeat" has incompatible type "str"; expected "int"
That separation is the whole model: you write intent, a tool verifies it. Nothing slows down at runtime.
Modern union syntax: | and Optional
Since Python 3.10 you can write unions with the pipe operator instead of importing Union. A value that might be missing is just "the type or None":
def find_user(user_id: int) -> str | None:
users = {1: "ada", 2: "linus"}
return users.get(user_id) # returns None if absent
name = find_user(3)
if name is not None:
print(name.upper()) # checker knows name is str here
The if name is not None guard is important. Inside that block the checker narrows the type from str | None to str, so calling .upper() is allowed. Outside it, calling a string method on a possible None would be flagged. str | None is equivalent to the older Optional[str]; both are fine, but the pipe reads better.
Collections: prefer built-in generics
You no longer need from typing import List, Dict. Since Python 3.9 the built-in containers are subscriptable directly:
def average(scores: list[float]) -> float:
return sum(scores) / len(scores)
def tally(words: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
return counts
When you only consume a collection and don't care about the exact concrete type, annotate the parameter with an abstract type from collections.abc so callers can pass any compatible object:
from collections.abc import Iterable
def total(numbers: Iterable[int]) -> int:
return sum(numbers)
total([1, 2, 3]) # list
total((4, 5, 6)) # tuple
total(range(10)) # range — all accepted
Generics: writing code that preserves types
A generic function works over many types while keeping the relationship between input and output. Python 3.12 introduced clean syntax for this with PEP 695 — a type parameter written in square brackets right after the name:
# Python 3.12+
def first[T](items: list[T]) -> T:
return items[0]
x = first([1, 2, 3]) # checker infers x: int
y = first(["a", "b"]) # checker infers y: str
The same idea builds generic classes — a container whose element type is remembered:
# Python 3.12+
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
numbers: Stack[int] = Stack()
numbers.push(10)
value = numbers.pop() # checker knows value: int
If you are still on Python 3.10 or 3.11, the equivalent uses an explicit TypeVar and Generic:
from typing import TypeVar, Generic
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
TypedDict: shape for your dictionaries
Dictionaries with a fixed set of string keys — API payloads, config blobs, JSON records — are everywhere. TypedDict describes their structure so the checker can catch typos and missing keys:
from typing import TypedDict
class Movie(TypedDict):
title: str
year: int
film: Movie = {"title": "Arrival", "year": 2016}
print(film["titel"]) # checker error: unknown key "titel"
It's a hint only — at runtime film is an ordinary dict with zero overhead. You get validation from the checker without changing behavior or reaching for a full model library.
Protocols: typing by behavior, not inheritance
Python's culture is duck typing: if it has the methods I need, I'll use it. Protocol makes that explicit and checkable without forcing anyone to inherit from a base class. Any object with a matching method structurally satisfies the protocol:
from typing import Protocol
class HasLength(Protocol):
def __len__(self) -> int: ...
def describe(obj: HasLength) -> str:
return f"length is {len(obj)}"
describe([1, 2, 3]) # list has __len__
describe("hello") # str has __len__
describe({"a": 1}) # dict has __len__
None of those types were declared to implement HasLength — they simply match its shape. Add @runtime_checkable above the protocol if you also want isinstance() checks to work at runtime.
Overloads: one function, many signatures
Sometimes a function's return type depends on the type of its argument. @overload lets you describe each case precisely so callers get an accurate inferred type:
from typing import overload
@overload
def parse(x: int) -> str: ...
@overload
def parse(x: str) -> int: ...
def parse(x: int | str) -> str | int:
return str(x) if isinstance(x, int) else int(x)
a = parse(42) # checker infers a: str
b = parse("42") # checker infers b: int
The stub lines describe the interface; the final undecorated definition holds the real logic. The checker matches the call against the overloads, while at runtime only the last function exists.
Type aliases for readability
When a type gets long or meaningful, give it a name. Python 3.12 added a dedicated type statement:
# Python 3.12+
type Vector = list[float]
type ConnectionOptions = dict[str, str | int | bool]
def scale(v: Vector, factor: float) -> Vector:
return [x * factor for x in v]
On older versions, assign the alias directly: Vector = list[float]. Named aliases make signatures self-explanatory and let you change one definition in a single place.
Common pitfalls
Mutable default arguments. A type hint won't save you from the classic trap of a shared default list. Annotate it, but still default to None:
def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags.append(tag)
return tags
Forgetting to run a checker. Hints you never check are just comments. Add mypy or pyright to your CI so mismatches fail the build, and enable it in your editor for instant feedback.
Reaching for Any too soon. Any silences the checker completely and spreads through your code. When a type is genuinely unknown, prefer object (which forces you to narrow before use) and reserve Any for true escape hatches.
Over-annotating obvious locals. You rarely need count: int = 0; the checker infers it. Focus your annotations on function signatures and class attributes — the public surfaces where they document intent and catch the most bugs.
Wrap-up and next steps
Type hints pay off most at boundaries: function parameters and returns, class attributes, and the shapes of data crossing between modules. Start there. Reach for | None and built-in generics for everyday code, TypedDict for structured dicts, Protocol when you care about behavior rather than lineage, and PEP 695 generics when a function or class should work across many types.
From here, install mypy or configure Pylance and run it over an existing project — you'll likely surface a real bug within minutes. Then explore get_type_hints() for introspection, typing.Literal for restricting values to a fixed set, and gradually raising your checker's strictness as your codebase gets cleaner. The goal isn't a perfectly annotated program; it's catching mistakes earlier, with tools doing the checking for you.