🪴 GoDeep
Search
← Shipping Software

Types as documentation

How to read Python type hints and TypeScript types as the fastest route into unfamiliar code, what mypy, pyright and tsc really check, why Any and 'as any' are smells, and how Pydantic models act as the contract between a FastAPI back end and a React front end.

12 cards · 6 quiz questions · 10 min read

An agent built you a research-automation service: a FastAPI back end, a React dashboard, forty-odd Python modules you have never read. You need to change one thing. Reading it all is not realistic, and the comments — where there are any — describe intentions rather than behaviour.

The fastest way in is the type annotations. They are the only part of the code that states, in one line, what a function expects, what it hands back, and what can be missing. Unlike comments, they are checked by a tool, so they rot far more slowly. Read as documentation, they let you map a system in minutes rather than hours.

What a signature tells you before you read the body

async def summarise_wave(
    survey_id: str,
    wave: int,
    responses: list[Response],
    max_quotes: int = 3,
    client: LLMClient | None = None,
) -> WaveSummary:

Without opening the body you now know: it is asynchronous, so it is meant to be awaited and probably does network work. It takes a list of Response objects, which is a class you can go and read. client is optional, so somewhere inside there is a branch that builds a default one. And it returns a WaveSummary, which is your next stop — that class is the real answer to “what does this module produce?”.

The one to slow down on is LLMClient | None. A union with None marks a value that can legitimately be absent, which is precisely where production AttributeErrors come from. In an unfamiliar codebase, the optional annotations are a map of the places that crash.

Python hints are not enforced — except where they are

This trips up everyone reading Python for the first time. PEP 484 annotations are metadata. The interpreter does not check them:

def count_rows(path: str) -> int:
    return "forty-two"      # runs perfectly happily; Python never checks the annotation

Nothing raises. The annotation is a claim, verified only if you run a separate checker. The two you will meet are mypy and pyright (the engine inside the Pylance extension in VS Code). Both read your code without running it and report contradictions.

The important exception is a framework that reads annotations on purpose. Pydantic does, and so FastAPI does, and that changes them from advice into a runtime gate — more on that below.

What mypy actually checks — and the trap

Here is the fact that explains most “but CI was green” stories:

# mypy checks this: it knows survey_id is a str
def load(survey_id: str) -> int:
    return survey_id.upper() + 1      # error: unsupported operand types

# mypy skips this entirely, by default
def load(survey_id):
    return survey_id.upper() + 1      # no error reported at all

By default mypy does not check the body of a function with no annotations at all — it treats it as dynamically typed and walks past. A codebase with a scattering of hints can therefore pass cleanly while most of it has never been examined. Two settings close the gap:

# pyproject.toml
[tool.mypy]
python_version = "3.12"
disallow_untyped_defs = true     # every function must be annotated, so none get skipped
disallow_any_generics = true     # a bare `list` or `dict` is an error; say what is in it
warn_return_any = true

Turn those on in an agent-written repo and expect a large number of errors the first time. That number is the honest measure of how much of the code was actually being checked.

Any is a hole, and it spreads

Any means “stop checking this”. You can call any method on it, index it, add it to anything. The part people miss is that it is contagious:

from typing import Any

def parse(payload: Any) -> None:
    rows = payload.items          # Any
    first = rows(0)               # still Any — mypy cannot object to anything downstream
    first.nonexistent_method()    # accepted, and fails at runtime

An attribute of an Any is Any; the result of calling it is Any. So a single annotation can hollow out checking across an entire call chain. Two silent sources of it: a parameter with no annotation is implicitly Any, and a bare list is list[Any].

If what you mean is “some object, I don’t know which”, object is the honest choice — it is a real type, and the checker makes you narrow it with an isinstance check before you use it. That is the difference between admitting ignorance and disabling the alarm.

The TypeScript half

tsc, the TypeScript compiler, does the same job for your React code, with the same crucial limit: all types are erased before the code runs. Nothing about real data is ever verified. tsc checks that your code is internally consistent with the types you wrote, and no more.

Which makes as any the front-end equivalent of except Exception: pass:

// Agent version: the build error goes away, the mismatch does not
const res = await fetch("/api/waves/12");
const rows = (await res.json()) as any;
return <Table rows={rows.items} />;     // rows.items may not exist; nobody checked
// Better: describe the shape, and handle failure
type Wave = { id: string; label: string; n: number };
const res = await fetch("/api/waves/12");
if (!res.ok) throw new Error(`waves request failed: ${res.status}`);
const data: { items: Wave[] } = await res.json();   // still a claim, but a checkable one
return <Table rows={data.items} />;

A type assertion (as any, or as Wave[]) tells the compiler to stop objecting. It is not a conversion and not a check. as SomeType on API data is the same problem in better clothes: it asserts that the JSON has a shape nobody verified.

The setting that decides how much your front-end types are worth is strict:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

strict is an umbrella. The member that matters most is strictNullChecks: with it off, null and undefined are assignable to every type, so the compiler says nothing about exactly the mistakes that produce “cannot read properties of undefined” in a dashboard. It also switches on noImplicitAny, turning every untyped parameter from a silent hole into an error. Checking whether strict is on is a two-second read of tsconfig.json and tells you a great deal.

Pydantic: the one place types become real

A Pydantic model is a type that validates actual data:

from pydantic import BaseModel, field_validator

class WaveSummary(BaseModel):
    survey_id: str
    wave: int
    n_responses: int
    headline: str
    quotes: list[str] = []

    @field_validator("wave")
    @classmethod
    def wave_must_be_positive(cls, v: int) -> int:
        if v < 1:
            raise ValueError("wave must be 1 or greater")
        return v

WaveSummary.model_validate(payload) either gives you an object whose fields match those declarations, coercing where it safely can, or raises a ValidationError naming each bad field. model_dump() goes the other way. This is Pydantic v2 — if you see parse_obj or .dict() in agent code, it is writing v1 against a v2 library.

FastAPI builds on this, and the return annotation does more than document:

@router.get("/waves/{wave_id}")
async def get_wave(
    wave_id: str,
    session: Annotated[AsyncSession, Depends(get_session)],
) -> WaveSummary:                     # filters the response to these fields only
    return await load_wave(session, wave_id)

If load_wave returns an ORM object carrying internal_notes and created_by_email, they do not reach the client — FastAPI serialises only the fields the response model declares. So the annotation is also a data-leak control, and any change that widens it is a security-relevant change.

The contract between the two halves

FastAPI turns those models into an OpenAPI schema, served at /openapi.json and browsable at /docs. That schema is the authoritative description of your API.

This matters because of a specific, common failure. If your React app hand-writes its own interface to match the Python model, tsc will happily check your components against it forever — including after the back end renames a field. The build stays green. The dashboard renders undefined. TypeScript never sees the real response and has no way to know.

Two defences, and you want at least one: generate the TypeScript types from /openapi.json so they cannot drift, or validate responses at the boundary with a runtime schema library so a mismatch throws a clear error where it happened.

A reading order for an unfamiliar repo

  1. The Pydantic models. They are the nouns of the system — read them and you know what the service is about.
  2. The routers. Each signature says which model an endpoint takes and returns, so you get the whole API surface without reading a handler.
  3. /docs in a browser, to see the same thing as an API and try a call.
  4. On the front end, the types file the API client uses — then compare it with /openapi.json.

Step four is where bugs live.

What to check in your own code

  • Does pyproject.toml set disallow_untyped_defs? If not, a green mypy run means very little.
  • grep -rn ": Any\|-> Any" app/ — for each hit, could it be object or a real type?
  • grep -rn "as any" src/ in the front end, and treat each as an unproven assumption about data.
  • Open tsconfig.json and confirm strict is true.
  • grep -rn "parse_obj\|\.dict()" app/ for Pydantic v1 idioms written against v2.
  • Check whether your React types are generated from /openapi.json or typed by hand — and if by hand, diff them against the live schema today.

Sources

  • Guido van Rossum, Jukka Lehtosalo & Lukasz Langa — PEP 484 - Type Hints paper The proposal that introduced Python annotations, and states explicitly that they are not enforced at runtime.
  • mypy documentation — Dynamically typed code website Confirms that mypy skips the bodies of unannotated functions, that Any disables checking locally and spreads, and names the disallow-untyped-defs and disallow-any-generics flags.
  • TypeScript Handbook — Everyday Types website Covers unions, interfaces and type assertions, including the fact that an assertion is removed at compile time and is never checked.
  • Pydantic documentation — Models website Pydantic v2 model behaviour - model_validate and model_dump - which is what makes FastAPI's annotations enforced at runtime rather than advisory.
  • FastAPI documentation — Response Model website How a return annotation both filters the outgoing response and generates the OpenAPI schema the front end can be built from.