Python Type Hints in 2024: What Actually Earns Its Keep
There’s a specific kind of Python developer I keep meeting, and I was one for years: you know type hints exist, you put -> str on a function once in a while, you have never run a type checker, and when someone shows you a signature with three nested brackets in it you quietly close the tab. That position was more defensible in 2016 than it is now, not because typing became mandatory, but because the syntax stopped being a tax and a few of the pieces turn out to be good at describing the messy shapes real code already passes around. So here’s an honest pass at which parts are worth your time and which still cost more than they give back.
Nothing is checked when you run it
Start here, because a lot of people are fuzzy on it. Annotations do nothing at runtime. The typing module docs put it in a box at the top of the page: the Python runtime does not enforce function and variable type annotations. PEP 484, which started all this in 2014, says annotations are available through __annotations__ but no type checking happens at runtime, and adds that Python will remain a dynamically typed language and the authors have no desire to ever make type hints mandatory, even by convention.
You can watch it not happen:
def greet(name: str) -> str:
return "hello " + str(name)
print(greet(42)) # hello 42
print(greet.__annotations__) # {'name': <class 'str'>, 'return': <class 'str'>}
That runs fine. The annotation is just an object in a dictionary on the function, a message to a tool rather than a guard rail. So if you were hoping annotations would validate the JSON coming off a webhook, they will not. But it also means adding hints to a running service cannot break it the way a real runtime check can, which is why gradual adoption works at all.
The syntax tax is mostly gone
The old objection was that annotated Python stopped looking like Python, and it was fair. Optional[Dict[str, List[int]]] is not a nice thing to read, and it needed three imports.
Two changes fixed most of it. Builtins became subscriptable, so list[str] and dict[str, int] work directly and typing.List and typing.Dict are now deprecated aliases. And PEP 604 landed the union operator in Python 3.10, so you write int | str instead of Union[int, str] and str | None instead of Optional[str]. It works in isinstance too:
>>> isinstance(3, int | str)
True
>>> type(int | None)
<class 'types.UnionType'>
Compare the two versions of the same signature:
from typing import Optional, Dict, List
def load(path: str, limit: Optional[int] = None) -> Dict[str, List[int]]: ...
def load(path: str, limit: int | None = None) -> dict[str, list[int]]: ...
The second has no imports and reads like a sentence. If the only thing stopping you was that annotated code looked ugly, that complaint expired around Python 3.10. Annotating signatures and nothing else is most of the value for most codebases: your editor gets better at completion and rename, and a checker finds the places where a function that can return None is used as though it cannot, which is where a decent share of production AttributeError tracebacks come from.
Protocol is the one for duck typing
This is the piece I wish I had understood earlier, because it fixes what made typing feel un-Pythonic. Normal subtyping is nominal: a class counts as an Animal because it inherits from Animal. Python has never really worked that way. We pass around anything with a .read() and get on with our day.
PEP 544 adds structural subtyping, which is static duck typing. You declare the shape you need, and anything with that shape qualifies, with no inheritance and no registration:
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def shut_down(resource: Closeable) -> None:
resource.close()
A socket, a file, a database handle from a library you don’t control, your own class written before this protocol existed: all fine, and none of them need to know Closeable exists. Much better than retrofitting abstract base classes onto third party objects.
The caveat is the runtime side. Protocols are static by default, and an isinstance call against one raises TypeError saying instance and class checks can only be used with @runtime_checkable protocols. Add that decorator and isinstance works, but it only checks that the named attributes exist. A class whose close takes three required arguments and returns a string still passes. It’s a smoke test, not validation, which is worth knowing before you build a dispatch layer on it.
TypedDict for the dicts you already have
Half the data in a typical service is dict shaped because it came out of json.loads, and converting all of it to dataclasses is a bigger refactor than anyone wants. PEP 589 lets you describe those dicts in place:
from typing import TypedDict
class Movie(TypedDict):
name: str
year: int
def record(movie: Movie) -> None: ...
Now a checker knows that movie["yaer"] is a typo, that movie["year"] = "1982" is the wrong value type, and that a literal missing name is incomplete. Use total=False when every key is optional. At runtime it is still a plain dict, literally: type(Movie(name="Blade Runner", year=1982)) is dict, and assigning an undeclared key succeeds silently. isinstance(d, Movie) raises a TypeError saying TypedDict does not support instance and class checks, which is deliberate, since checking value types needs inspection isinstance cannot do.
My rule of thumb is that TypedDict suits data that arrives as a dict and leaves as a dict, particularly API payloads at the edge of a system. Once you’re adding methods, defaults, or validation around a shape, you wanted a dataclass and should say so.
Generics, and when to bother
Most people never need to define a generic. You consume them constantly (list[str] is one), and defining your own only pays off when a function or container relates its input type to its output type: a cache, a result wrapper, a first(items) helper. A TypeVar that says “this returns whatever you gave it” is good. Three of them plus a bound, to describe a function that could have taken two arguments, is the type system telling you something about the design.
Python 3.12 made the declaration much less ceremonial with PEP 695. The type parameter goes in brackets after the name, there’s no module level TypeVar, and there’s a type statement for aliases:
def first[T](items: list[T]) -> T:
return items[0]
class Box[T]:
def __init__(self, item: T) -> None:
self.item = item
type Point = tuple[float, float]
One practical warning. The interpreter accepts all of that, but mypy 1.10, the current release as I write this, does not understand the new type parameter syntax yet, so using it means the checker errors or skips the very code you were describing. If you’re on 3.12 and you type check, stick with TypeVar a while longer.
The other small 3.12 addition is worth adopting immediately, because it costs nothing. PEP 698 added @typing.override, marking a method as intentionally overriding a parent. Rename the parent method and the checker tells you the child now overrides nothing, instead of leaving you dead code that silently stopped being called. At runtime it sets __override__ = True and does nothing else.
Turning mypy on without a two week outage
The failure mode is running mypy on the whole repo, getting four thousand errors, and deciding typing is not for you. Mypy’s own guide for existing codebases says not to do that. Pick a subset, five thousand to fifty thousand lines, and get mypy passing on it before you add a single annotation, silencing what you must with # type: ignore. Then pin the version, check a config file into the repo, and wire it into CI so the error count can only go down.
The per module sections are the whole trick. Set a lax global baseline and tighten one package at a time:
[mypy]
warn_unused_configs = True
[mypy-legacy.*]
ignore_errors = True
[mypy-billing.*]
disallow_untyped_defs = True
[mypy-somelibrary]
ignore_missing_imports = True
The default that surprises people is check_untyped_defs, which is off. Mypy does not type check the body of a function that has no annotations, so a module can be “passing” while most of it is invisible, and the first time you annotate a function you get a pile of new errors from inside it. That is not a regression, it is the checker finally being allowed to look. Turning it on early is the single change that tells you where you actually stand.
From there the strictness dials are a ratchet. disallow_untyped_defs on a finished package stops new unannotated code sneaking in. The docs suggest aiming for --strict eventually, and note you can start from strict = True and subtract the flags that hurt, which is usually faster once a package is in decent shape.
Where it gets in the way
Being fair about the costs. Decorators and metaclass heavy frameworks are where checkers still struggle, and you will write # type: ignore where you are clearly right and the tool is clearly wrong. Third party libraries without stubs push Any into your code, and Any is quietly contagious: everything it touches stops being checked, so your coverage ends up worse than it looks. Refactors get slower, because changing a shape means changing every annotation that mentions it. And teams do start optimising for the checker instead of the reader, producing a signature nobody can parse for a function that does something simple.
None of that argues for zero typing. It argues for the boring subset: annotate function signatures, especially anything that sometimes returns None, reach for Protocol rather than an abstract base class when you’re describing what you need from an object, use TypedDict for payloads that are dicts and will stay dicts, leave generics alone until a function genuinely passes a type through, and run mypy in CI with a config that lets you tighten one package at a time.
That set is maybe a day of work on an existing project and it changes no runtime behaviour at all, which took me too long to appreciate. The type checker is a linter with a very good model of your data flow. Treat it like one and it earns its keep. Treat it like a compiler you have to satisfy and it will make your code worse.