Tooling & Integration

Types as the Contract: A Decision Procedure Downstream of a Generator

David Guzenburg/ / 8 min read

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.

type systemsstatic analysisverificationspecification

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.

SettingOld objectionNow
Annotations required everywhereTedious to writeWritten for free; catches more
No implicit AnyNoisy on legacy codeScope it, then let the agent narrow types incrementally
Exhaustive match checkingVerboseThe single best guard against a new enum variant being missed
Null-safety enforcedCeremonyThe 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.
The suppression this invites

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.

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.

Takeaway

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.

Keep reading
Tooling & Integration

Static Analysis in the Loop: Linters as Feedback, Not Just Gates

Machine-readable linter output, why agents suppress rather than fix, a CI check that blocks it, and which rules are worth enabling once an agent actions them for free.

Tooling & Integration

Build System Integration: Your Toolchain Is the Agent's Feedback Loop

Why build quality bounds agent output, four properties that matter, the failure modes specific to agents, and why you should verify your check command actually fails on failure.

Context Architecture

Writing Build and Test Instructions an Agent Can Actually Execute

Five ways 'run the tests' fails in practice, how to separate the fast loop from the merge gate, and why pointing at one CI-backed script beats fifteen lines of prose.

Context Architecture

The AGENTS.md Specification: Anatomy of a Repository Context File

What belongs in an AGENTS.md file, the six sections that carry the weight, why short files outperform comprehensive ones, and where context files stop working.

← Sandboxing Agent Environments for Reproducibility, Not Just Safety  ·  Running Coding Agents in CI: Guardrails for Unattended Work →

All tooling & integration articles  ·  Every article