Types as the Contract: A Decision Procedure Downstream of a Generator
A model samples plausible text. A type checker decides. Putting the second downstream of the first is why the same model writes better code in a strictly typed repository.
A type system is the cheapest correctness signal you have
A model generating code is sampling from a distribution over plausible text. A type checker is a decision procedure. Putting the second downstream of the first converts "looks right" into "is consistent", which is not correctness but is a large fraction of it and costs nothing per check.
This is why the same model produces measurably better output in a strictly typed codebase than in an untyped one. Nothing about the model changed — it is getting a signal it can converge against.
Strictness is worth more than it used to be
Type-checker settings have always traded developer effort against safety. Agents change that calculus, because the effort side is now paid by something that does not mind.
| Setting | Old objection | Now |
|---|---|---|
| Annotations required everywhere | Tedious to write | Written for free; catches more |
No implicit Any | Noisy on legacy code | Scope it, then let the agent narrow types incrementally |
| Exhaustive match checking | Verbose | The single best guard against a new enum variant being missed |
| Null-safety enforced | Ceremony | The most common class of generated bug |
[tool.mypy]
strict = true
warn_unreachable = true
warn_return_any = true
disallow_any_explicit = true # noisy, and worth it here
# Each override below is a place agents will produce weaker code.
# Treat the list as debt and shrink it.
[[tool.mypy.overrides]]
module = ["src.legacy.*"]
ignore_errors = true
Exhaustiveness is the pattern that pays
Adding a variant to a union and missing one of the places that handles it is a defect agents produce reliably — they update the sites they read and not the ones they did not. Exhaustiveness checking turns that from a runtime surprise into a compile error.
from typing import Literal, assert_never
Status = Literal["pending", "shipped", "cancelled"]
def describe(s: Status) -> str:
match s:
case "pending": return "Awaiting dispatch"
case "shipped": return "On its way"
case "cancelled": return "Cancelled"
case _:
assert_never(s) # adding a variant fails HERE, at check time
## Types
- Every match over a union ends with `case _: assert_never(x)`.
Never a default that returns a fallback value — that hides
the missing case instead of surfacing it.
- Adding a variant to a Literal union means fixing every
assert_never the checker flags. Do not add a catch-all to
make them pass.
An agent facing four exhaustiveness errors after adding a variant may add a
catch-all default to each, which compiles and defeats the entire mechanism.
Name that specifically as forbidden, and check for it in CI — new
case _: branches that return rather than assert are worth
flagging.
Types are also the specification
Beyond checking, a signature is the most efficient instruction you can give. Compare the token cost of describing a function in prose against declaring it.
Implement this. Do not change the signature.
def allocate_stock(
order: Order,
warehouses: Sequence[Warehouse],
*,
strategy: AllocationStrategy = AllocationStrategy.NEAREST,
) -> Result[Allocation, AllocationError]: ...
Constraints
- Return Err(...) for insufficient stock. Do not raise.
- AllocationError variants are in domain/errors.py — use those,
do not add new ones.
- Pure function. No I/O; warehouses are passed in, not fetched.
The signature carries most of the specification. The Result
return type says errors are values, the keyword-only argument says the strategy
is optional and named, and the parameter types say what it operates on. Three
prose constraints cover what the types cannot express.
Adopting strictness on a codebase that is not ready
Turning strict mode on across a large untyped codebase produces four thousand errors and gets reverted the same afternoon. The workable path is per-module, with the exclusion list treated as a shrinking debt register.
#!/bin/bash
# Fail if the number of excluded modules grows.
CURRENT=$(grep -c '^module = ' pyproject.toml)
BASELINE=$(cat .mypy-exclusion-baseline)
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "Type-check exclusions grew from $BASELINE to $CURRENT." >&2
exit 1
fi
if [ "$CURRENT" -lt "$BASELINE" ]; then
echo "$CURRENT" > .mypy-exclusion-baseline
echo "Exclusions down to $CURRENT. Baseline updated."
fi
A ratchet rather than a cliff. New code is strict from the start, the excluded set can only shrink, and narrowing one module is a well-shaped task to hand an agent — bounded, mechanically verifiable, and tedious.
Where types do not help
Worth being clear, because it is easy to over-claim here.
- Correct types, wrong logic. A function returning
Result[Allocation, AllocationError]that allocates from the furthest warehouse type-checks perfectly. - Types that are lies. Anything at a boundary — deserialised JSON, database rows, external APIs — is typed by assertion rather than by proof. Validate at the boundary; the annotation is a claim, not a check.
- Dynamic escapes.
getattr, reflection, metaclass tricks and monkeypatching are invisible, and generated code occasionally reaches for them.
Making the errors legible
Type-checker output is written for humans and some of it parses badly. Two flags change what the agent can do with a failure.
## Type checking
- `mypy src/ --no-error-summary --no-pretty --no-color-output`
One error per line as `file:line: error: message`. Do not use
the default pretty output — the source excerpts and carets
waste context and are harder to act on.
- Fix errors top to bottom. Later errors are frequently caused
by earlier ones and disappear on their own.
The second line prevents a real waste: an agent that fixes the last error first often finds the remaining twelve were cascading from one bad annotation near the top, and has spent turns on symptoms.
Ordering the feedback
Types before tests, in the loop. A type error is faster to produce, points at an exact location, and frequently explains a test failure that would otherwise take several turns to diagnose.
#!/bin/bash
set -e
mypy src/ --no-error-summary --no-pretty # fastest, most precise
ruff check --output-format=concise .
pytest tests/unit -q --tb=line # slowest, most meaningful
Ordering fast-and-precise first means the agent spends its turns on errors that name a line, rather than on a stack trace it has to interpret.
A type checker is a decision procedure downstream of a probabilistic generator, and strictness costs less than it used to because the annotation burden falls on the agent. Use exhaustiveness checking as the guard against missed union variants, forbid catch-all defaults explicitly, and write signatures rather than prose — they are the densest specification available.