Contents

Python Type Hints: Writing Python Like You Mean It

Optional typing that pays for itself in the codebases you actually have to maintain

Contents

Python’s great selling point is that you can write a function in ten seconds without telling the interpreter anything about what goes in or comes out. Its great curse is that six months later you are staring at that same function trying to remember whether data is a list, a dict, or a generator that has already been consumed. Type hints are Python’s answer to its own success: a way of writing down what you meant, so that your tools, your colleagues, and your future self can hold you to it. I have watched them turn a genuinely scary refactor of a forty-file service into an afternoon of following red underlines, and that experience converted me for good.

What hints actually do (and don’t)

Advertisement

The first thing to understand is that Python does not enforce type hints at runtime. The interpreter cheerfully ignores them. Annotate a parameter as int, pass it a string, and Python will shrug and run anyway until something downstream explodes. Hints are documentation that happens to be machine-checkable, and the machine doing the checking is a separate tool, usually mypy or your editor’s language server, not the interpreter.

That sounds like a weakness and it is the single most common reason people dismiss them. But it is also the design that let Python adopt typing gradually. You can annotate one function in a million-line codebase and nothing else has to change. The hints are opt-in, file by file, and they compose: a typed function calling an untyped one simply loses visibility at the boundary rather than erroring.

Here is the shape of it:

1
2
3
4
def average(values: list[float]) -> float:
    if not values:
        raise ValueError("average() of empty sequence")
    return sum(values) / len(values)

The : list[float] and -> float are the whole feature. Run mypy over that file and it will now flag callers that pass a dict, or that try to treat the result as a string. This is the same discipline of writing down intent that I argue for elsewhere; if you want the wider case for why static-ish checking earns its keep in a dynamic language, my good, bad and ugly tour of Python puts type hints squarely in the “the fix already exists, use it” column.

The constructs worth knowing

Most real code needs more than int and str. The ones I reach for constantly are Optional, which is shorthand for “this or None”, and Union for “one of these”. As of Python 3.10 you can write both with the | operator, which reads far better:

1
2
def find_user(uid: int) -> User | None:
    return _users.get(uid)

That return type is a promise and a warning at once: callers must handle the None, and mypy will nag them until they do. This single pattern catches a startling proportion of the AttributeError: 'NoneType' object has no attribute failures that plague untyped Python.

For anything structural, dataclasses and TypedDict earn their keep. A dataclass turns a bag of attributes into a named, typed record with a free __init__, while TypedDict lets you describe the shape of a dictionary you are stuck with — the JSON blob from some API you cannot change:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from dataclasses import dataclass
from typing import TypedDict

@dataclass
class User:
    id: int
    name: str
    active: bool = True

class ApiResponse(TypedDict):
    status: str
    results: list[dict[str, str]]

And when you want to accept “anything that behaves like a file” rather than a specific class, Protocol gives you structural typing: define the methods you need, and any object with those methods qualifies, no inheritance required. This is duck typing, but the checker verifies the duck actually quacks:

1
2
3
4
5
6
7
from typing import Protocol

class Readable(Protocol):
    def read(self, size: int = -1) -> bytes: ...

def load(source: Readable) -> bytes:
    return source.read()

The other two I use weekly are Literal, for functions that only accept specific string values (mode: Literal["r", "w", "a"]), and generics via TypeVar, for writing a container or helper that preserves the type it was given rather than flattening everything to Any.

Retrofitting hints onto code you already have

Advertisement

The theory is tidy; the reality is a codebase written years ago by people who have left. Annotating it all at once is a fool’s errand, and you should not try. The strategy that works is to type from the outside in. Start at your public API — the functions other modules call — because those signatures are where a wrong type does the most damage and where the documentation value is highest. Leave the private helpers untyped for now; a typed caller of an untyped function still gets checked up to the boundary.

There is a tool that does the tedious part for you. MonkeyType runs your test suite (or production traffic, if you are brave), watches the actual types flowing through each function, and generates hints from what it observed:

1
2
$ monkeytype run pytest
$ monkeytype apply mypackage.orders

Treat its output as a first draft, not gospel — it can only infer types it actually saw, so a function only ever called with int gets annotated int even if it legitimately accepts float. But as a way to get eighty percent of the annotations written so you can hand-correct the rest, it is a huge accelerant. Combine it with mypy’s --disallow-untyped-defs switched on for one module at a time, and you have a ratchet: each module you finish gets locked to “fully typed” and can never silently regress.

Living with mypy

Adding hints is easy; running the checker honestly is where the discipline lives. My advice is to start lax and tighten over time. Drop a mypy.ini in your project and begin permissive:

1
2
3
4
5
6
7
[mypy]
python_version = 3.10
warn_unused_ignores = True
warn_return_any = True

[mypy-thirdparty.*]
ignore_missing_imports = True

That last stanza is the pragmatic escape hatch for libraries that ship no type information. You will need it more than you would like, though the situation has improved enormously as popular packages bundle their own stubs. Once your codebase is mostly clean, graduate to --strict, which switches on the full battery of checks at once — no implicit Optional, no untyped function bodies, no silent Any.

The trap is Any. It is the type that means “stop checking”, and it spreads. One Any returned from a function quietly disables type checking for everything that touches the result. warn_return_any exists precisely to catch this. Treat Any as a confession, not a tool — sometimes necessary at the edges of your system, never something to scatter through the core.

Troubleshooting: the errors that trip people up

Type checking has its own set of “why is it yelling at me” moments, and they follow patterns:

  • “Incompatible types in assignment” almost always means a variable is being reused for two different types. Split it into two named variables; the checker is telling you the code is confusing, not that it is wrong to complain.
  • Optional errors after a guard. If you write if user is not None: and mypy still complains inside the block, you have probably re-fetched user or the narrowing was broken by an intervening call. Narrowing is scoped and fragile — keep the guard and the use close together.
  • Third-party imports flagged as untyped. Install the stubs (types-requests, types-PyYAML, and friends), or add the ignore_missing_imports stanza above. Do not reach for a blanket # type: ignore — that switches off checking for the whole line, and warn_unused_ignores will later remind you it was pointless.
  • A stubborn false positive. Use a targeted # type: ignore[error-code] with the specific code mypy prints, not a bare ignore. That way the suppression only silences the one thing you meant, and the checker keeps working on everything else.

Run mypy in CI, not just locally, or the whole thing rots. A checker that only runs when someone remembers is a checker that is quietly always red. The same “make the machine enforce it” instinct applies to formatting and commits — I lean on the tooling for that too, as in AI-powered git commit messages.

What hints buy you that comments never could

The objection I hear most is “I already document my functions, why do I need this?” The answer is verification. A docstring that says data is a list of user dicts is a claim nobody checks; six months and three refactors later it says list of user dicts while the function has quietly started returning a generator of User objects, and the docstring is now actively lying to you. A type hint cannot rot the same way, because the checker fails the build the moment the annotation and the code disagree. The documentation and the enforcement are the same artefact.

The second, less obvious payoff is in your editor. A properly typed codebase gives your language server enough information to autocomplete attribute names, flag typos as you type them, and jump to the exact definition of a return type three modules away. Untyped Python turns the editor into a glorified text box that shrugs when you ask what result is. Typed Python turns it into something closer to what Java or C# developers have always had — and it is genuinely startling how much of the “Python doesn’t scale to large teams” complaint simply evaporates once the tooling can see the types.

There is a cost worth naming honestly. Hints add visual noise, and a heavily-generic signature can become a wall of brackets that is harder to read than the code it describes. dict[str, list[tuple[int, Optional[User]]]] is a real type I have written and immediately regretted; when a signature gets that baroque, it is usually telling you the data structure itself wants refactoring into a named dataclass. Used with judgement, hints clarify; piled on without it, they obscure. The skill is knowing when a TypeAlias or a small class would say the same thing more legibly than a nested generic.

Is it worth it?

For a thirty-line script you will run once and delete, no. The ceremony outweighs the payoff, and Python’s whole appeal there is the lack of ceremony. Don’t let anyone shame you into annotating a throwaway.

For anything that will outlive the afternoon — a library, a service, anything more than one person touches — type hints are among the highest-leverage habits you can pick up. They turn a category of runtime crashes into squiggly red underlines you see before you ever hit save. They make refactoring sane, because the checker tells you everywhere a signature change ripples to. And they document intent in a way comments never manage, because comments lie and types are verified.

The learning curve is gentle precisely because it is optional. Annotate the function that bit you last week, run mypy, and feel the small satisfaction of being told off by a robot before your users have the chance. That is Python written like you mean it.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.