Two apps, one shape of data
The API owns the data: leads, proposals, products, posts. The web app reads and writes it from the server, and only from the server; the browser never calls the API. Every request the site makes has a shape both sides must agree on: the path, the parameters, the body and the answer.
Without a contract, that agreement lives in two places written by hand. A Pydantic model in Python says a field is called min_minor; a TypeScript type somewhere in the web says the same thing, or used to. Nothing connects them. Each app's own tests pass, and the mismatch shows up as a broken form or an empty page.
The rule
A change to the API's routes or models regenerates the API's OpenAPI file and the web's types in the same commit. Both CI workflows fail on drift.
From a Python model to a TypeScript type
FastAPI already describes every route it serves as OpenAPI, with the models as JSON Schema (FastAPI's features page). We don't write the contract; we print it. A small module builds the app with local settings and writes its spec to apps/api/openapi.json, which is committed:
def render() -> str:
spec = create_app(Settings(environment="local")).openapi()
return json.dumps(spec, indent=2, ensure_ascii=False) + "\n"On the web side, npm run api:types runs openapi-typescript on that file and writes lib/api/schema.d.ts. The output is types only: nothing from it ships to the browser or runs on the server.
What the typed client catches
The generated types feed one client, built with openapi-fetch. It takes the paths as a type parameter, so every call names a path from the contract and gets the answer's type back:
export const api = createClient<paths>({
baseUrl: process.env.CORE_API_URL ?? "http://localhost:8000",
});
const { data, response } = await api.GET("/v1/public/posts/{slug}", {
params: { path: { slug }, query: { locale } },
signal: AbortSignal.timeout(TIMEOUT_MS),
});TypeScript then checks the parts that usually go wrong quietly:
- a path that doesn't exist, or a typo in one;
- a missing path parameter, like the post's
slug; - a body field the API doesn't accept, or a required one left out;
- reading a field from the answer that the API doesn't send.
Every one of these fails npm run typecheck, on the developer's machine and in CI, before a page is built.
The admin goes through the same client. Leads, proposals, products and posts are read and written with paths from the contract, and the staff token each admin call carries is a header the contract declares, so TypeScript also knows which calls need one.
Two checks that fail on drift
Generated files only help if they are current. So each workflow regenerates its file from scratch and asks git whether anything changed:
# api.yml
- name: openapi.json matches the code
run: |
uv run python -m app.openapi > openapi.json
git diff --exit-code -- openapi.json
# web.yml
- name: API types match apps/api/openapi.json
run: |
npm run api:types
git diff --exit-code -- lib/api/schema.d.ts
When a check fails, its error names the fix: the command to run and the file it rewrites. The developer runs it, commits the result and pushes again. Nobody edits a generated file by hand, and nobody has to remember which side changed first.
The first check fails when someone changes a route or a model and forgets to print the spec. The second fails when the spec changed and the types didn't. The web workflow also runs when apps/api/openapi.json changes, not only when files in apps/web do, so a pull request that only touches the API still has to bring the web's types along.
Because both files are committed, the contract shows up in code review. A changed field is a few lines of diff in openapi.json that a reviewer can read without opening the Python.
A real change: the budget as a range
On October 7 we changed how a brief's budget is stored. It had been a fixed choice from a list; it became a range in cents, open at either end, with a currency. The API side is a Pydantic model:
class BudgetRange(BaseModel):
"""What the visitor expects to spend, in the currency's minor unit (cents). Either end may be open."""
model_config = ConfigDict(extra="forbid")
min_minor: int | None = Field(default=None, ge=0, le=CEILING)
max_minor: int | None = Field(default=None, gt=0, le=CEILING)
currency: Literal["usd"] = "usd"And this is what the web got, without anyone writing it:
BudgetRange: {
/** Min Minor */
min_minor?: number | null;
/** Max Minor */
max_minor?: number | null;
/**
* Currency
* @default usd
* @constant
*/
currency: "usd";
};The commit touched 21 files across the API, the admin and the site. The contract part was 65 changed lines in openapi.json and 27 in schema.d.ts, both regenerated. With the new types, the contact form's call to POST /v1/public/leads couldn't compile until it sent the new shape: a 4-line change in lib/lead-submit.ts. The lead's page in the admin read the old field too, and changed in the same commit to show the range.
- operations on 61 paths in today's openapi.json
- 82
- schemas the web gets as TypeScript types
- 157
- commits that changed the contract since September 26
- 29
What the contract doesn't do
We don't write the contract; we print it.
- It checks types at build time, not data at run time. The API still validates every request with its Pydantic models; the web trusts the API's answers to match the file.
- It keeps the repo consistent. If an older API were running behind a newer web, TypeScript couldn't know.
- It doesn't make a change safe for old data. When the budget changed, the API kept reading briefs stored with the old choice as the same range; the contract only described the new shape.
Within those limits it removes a whole class of bug for the cost of two CI steps and one command to remember. When a platform has a backend and a frontend in different languages, it's the first thing we set up. If you're planning one, see how we build web platforms or see what fits.


