Type Parity from Database to Browser
You can ship quickly when your system is allowed to be boringly consistent.
The opposite is familiar: your database “knows” a column is a datetime, your backend treats it as a string, your frontend assumes it’s a Date, and the bug only appears in production when one tenant has an edge-case record. What’s missing isn’t intelligence — it’s parity.
This post shows a pragmatic approach that achieves type parity from database to user, with validation at each boundary:
- SQLModel defines the database-facing shape (and gives you Python types you can trust).
- FastAPI exposes explicit request/response models and emits an OpenAPI contract.
- A small typegen package runs Hey API to generate:
- TypeScript types
- a typed Axios client
- TanStack Query query options + query keys
- Zod schemas for runtime validation (the “trust but verify” step in the browser)
The contract pipeline
What this buys you is not “more types” — it’s a sequence of gates where invalid states get rejected early, ideally before a human ever sees them.
The “typegen package” pattern
Instead of sprinkling codegen commands across apps, treat codegen like a product:
Why a dedicated package?
- One place to configure inputs/outputs/plugins.
- One place to attach conventions (naming, casing, query key strategy).
- One artifact boundary:
@acme/typegenbecomes the frontend’s “API surface”.
Step 1: Model once (DB ↔ Python) with SQLModel
Keep a strict separation between “DB table” and “API shape” when it matters (create/read/update can differ). You can still keep it compact.
That’s the first gate: Python types aligned with persistence.
Step 2: Make your API boundary explicit (Python ↔ JSON)
FastAPI will happily return “whatever”. Don’t. Choose a response model and let serialization/validation happen on purpose.
This is the second gate: runtime validation + a stable contract.
Step 3: Generate your client surface (OpenAPI ↔ TS)
Inside packages/typegen, point Hey API at your OpenAPI endpoint and pick the outputs you want.
Minimal config (React + Axios + Zod + query helpers):
A useful script:
Now the output is a real SDK surface you can import from your frontend — without hand-writing request types, response types, or query keys.
What the generated artifacts look like (concrete)
Here’s a trimmed slice of what the generated types and Zod schemas look like for the Manager model (names and filenames depend on your config, but the structure is representative):
The important part is that these are generated from the same contract the backend serves, so your compile-time types and runtime validators can’t silently drift apart.
Step 4: Configure Axios once
Your generated Axios client is only as good as your runtime defaults (baseURL, auth, headers, tenant routing, correlation IDs). Configure it once at app startup.
This avoids the “20 slightly different Axios instances” problem, and makes networking behavior boring (in the best way).
Step 5: Use TanStack Query without reinventing keys
With query helpers generated, UI code stops being “how do I call this endpoint” and becomes “what do I render”.
A subtle win: query keys stop being folklore. You don’t have to remember whether the key was ["managers"] or ["managerList"], or whether parameters were included consistently.
Step 6: Validate what the browser receives (because the browser lives in the real world)
TypeScript types are compile-time. The network is runtime. If you’ve ever debugged “this field is sometimes null,” you already know why Zod matters.
You can validate at the boundary:
Or you can let the generated SDK validate automatically (when configured to do so). The point is the same: untrusted data gets checked before it becomes app state.
Where validation happens
This is the core idea: each layer has a different kind of “truth,” and you want them to agree.
If you’re missing any gate, you get drift:
- Without a response model: backend returns “whatever”.
- Without generated clients: frontend redefines types by hand.
- Without Zod: runtime reality can slip past compile-time confidence.
Multi-tenant note: make tenant scope impossible to forget
If your backend is schema-isolated per tenant, treat tenantId as part of the identity of every tenant-scoped query.
Even if the generated keys are perfect, your architecture still needs one rule:
A tenant-scoped query key must encode tenant scope.
In practice, you can prefix keys with ["tenant", tenantId, ...] or ensure tenant context is included in the generated options. The goal is to make cross-tenant cache bleed an “obviously impossible” bug instead of a subtle one.
What you get in return
This setup tends to produce three compounding benefits. Together, they add resilience to the codebase and grace under change: fewer surprise breakages, clearer diffs, and a fast path from “schema changed” to “UI updated.”
-
Refactors become mechanical.
Rename a field in SQLModel → FastAPI schema changes → regenerated types → compiler points to every affected UI line. -
Bug investigation is faster. You stop asking “what shape is this object?” because the boundary enforces it.
-
Your caching story becomes legible. TanStack Query keys are generated, not improvised; the app’s server-state has a single source of truth.
Closing thought
Schema-driven systems are often framed as “more ceremony.” In practice, they’re less negotiation.
One more payoff: this is a data-driven programming stance. Your code is the machinery, your schemas and generated artifacts are the data. Making that separation explicit keeps behavior stable while contracts evolve, and it pushes change into versioned data instead of ad hoc logic.
You're moving ambiguity out of Teams chats and into artifacts your tools can check:
- constraints
- models
- OpenAPI
- generated code
- runtime validation
And once that loop is tight, you don’t just move faster — you move with less fear.