DEC20
SAT2025

Type Parity from Database to Browser

FastAPI + SQLModel → OpenAPI → Hey API → Axios, TanStack Query, and Zod.
fastapisqlmodelopenapihey-apiaxiostanstack-queryzodtypegen

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

Frontend
Typegen Package
Backend
schema + constraints
Pydantic validation
OpenAPI
React UI
@hey-api/openapi-ts
TypeScript types
Axios client
TanStack Query helpers
Zod schemas
SQLModel models
Database
FastAPI endpoints
openapi.json

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:

packages/
  backend/          # FastAPI + SQLModel
  typegen/          # runs openapi-ts and exports generated artifacts
  web/              # React app consumes @acme/typegen

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/typegen becomes 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.

# backend/models/manager.py
from sqlmodel import SQLModel, Field

class Manager(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)

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.

# backend/api/managers.py
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from .db import get_session
from ..models.manager import Manager

router = APIRouter()

@router.get("/managers", response_model=list[Manager])
def list_managers(session: Session = Depends(get_session)):
    return session.exec(select(Manager).order_by(Manager.name)).all()

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):

// packages/typegen/openapi-ts.config.ts
export default {
  input: "http://localhost:8000/openapi.json",
  output: "src/gen",
  plugins: [
    "@hey-api/client-axios",
    "@tanstack/react-query",
    "zod",
    { name: "@hey-api/sdk", validator: true },
  ],
};

A useful script:

// packages/typegen/package.json
{
  "scripts": {
    "generate": "openapi-ts"
  }
}

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):

// packages/typegen/src/gen/types.gen.ts
export type Manager = {
  id?: number | null;
  name: string;
};

export type ManagerList = Manager[];
// packages/typegen/src/gen/zod.gen.ts
import { z } from "zod";

export const zManager = z.object({
  id: z.number().int().nullable().optional(),
  name: z.string(),
});

export const zManagerList = z.array(zManager);
export type Manager = z.infer<typeof zManager>;

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.

// web/src/api/init.ts
import { client } from "@acme/typegen/gen/client.gen";

export function initApi() {
  client.setConfig({
    baseURL: import.meta.env.VITE_API_BASE_URL,
    // auth: () => token, // if you want SDK-managed auth
  });
}

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”.

// web/src/features/managers/useManagers.ts
import { useQuery } from "@tanstack/react-query";
import { getManagersOptions } from "@acme/typegen/gen";

export function useManagers() {
  return useQuery({
    ...getManagersOptions(),
    staleTime: 10 * 60 * 1000,
  });
}

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:

// web/src/api/validate.ts
import { zManager } from "@acme/typegen/gen/zod.gen";

export function parseManager(input: unknown) {
  return zManager.parse(input);
}

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.

DB constraints
NOT NULL, FK, UNIQUE
SQLModel types
Python correctness
FastAPI request/response models
runtime validation
OpenAPI contract
shared spec
Generated TS types
compile-time safety
Zod schemas
runtime safety in UI
User-visible state
what you render

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.”

  1. Refactors become mechanical.
    Rename a field in SQLModel → FastAPI schema changes → regenerated types → compiler points to every affected UI line.

  2. Bug investigation is faster. You stop asking “what shape is this object?” because the boundary enforces it.

  3. 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.