Implementation Plan: Auto-generate TypeScript types from FastAPI OpenAPI¶
Overview¶
The frontend currently hand-maintains every API response shape inline in store files (frontend/src/stores/auth.ts, workspace.ts, backoffice.ts, …). The backend exposes a complete OpenAPI 3.1 schema at /api/openapi.json (80 routes, 71 component schemas), but nothing reads it. As routes and Pydantic models evolve the hand-typed shapes silently drift — for example the existing User.id is typed string even though the backend emits integer (UserBase.id: Optional[int], UserRead.id resolves to number in the generated schema). This POC pulls the OpenAPI schema into a generated .d.ts and migrates one interface to prove the ergonomics end-to-end.
Goals (in scope for this PR)¶
- Survey the four credible OpenAPI-to-TypeScript tools in 2026 and pick one with a documented rationale.
- Ship the generator: dev dependency, script, Makefile target.
- Commit a snapshot of
openapi.jsonand the generatedopenapi.d.tsso IDE support andmake type-checkwork without running the backend. - Migrate exactly one hand-maintained interface (
Userinfrontend/src/stores/auth.ts) as the worked example.
Non-goals (deferred to follow-up PRs)¶
- CI step that regenerates types and fails the build on drift. Once the team is comfortable regenerating locally, a
make gen-api-types && git diff --exit-code src/types/api/openapi.d.tscheck belongs in the existing frontend CI workflow. - Migrating the remaining hand-maintained interfaces across
stores/workspace.ts,stores/backoffice.ts,api/audit.ts,api/locations.ts, etc. Best handled module-by-module by the owners of each domain; the pattern this PR establishes carries over directly. - Replacing the
kyHTTP client. The recommendation is deliberately types-only — keepingkymeans no behavioural change.
1. Tool survey (2026)¶
Four candidates evaluated. All four are mature and actively maintained; the differentiators are output shape, install footprint, and how intrusive they are on the existing ky-based HTTP layer.
| Tool | Output style | Install footprint | OpenAPI 3.1 / Pydantic 2 | Runtime client compatibility | Maintenance (2026) |
|---|---|---|---|---|---|
openapi-typescript (drwpow) | Pure .d.ts, zero runtime cost | One dev dep (~33 transitive), ~80ms run time | Yes — OpenAPI 3.0 and 3.1 | Any. Slots into ky, fetch, axios, … | Actively maintained, v7.13.0 at time of writing |
@hey-api/openapi-ts | Full SDK: client, types, optional zod, fetch wrappers | Multi-package; configured via openapi-ts.config.ts | Yes | Replaces the HTTP client with its own SDK | Actively maintained, broad plugin ecosystem |
orval | Generates TanStack/Vue Query hooks, MSW mocks | Heavyweight; opinionated framework integrations | Yes | Replaces both HTTP client and call sites | Actively maintained, popular in React Query stacks |
@openapitools/openapi-generator-cli | Templated Java-based generator (typescript-fetch, -axios, …) | Pulls a JRE; slow startup | Mostly yes; 3.1 support uneven across templates | Replaces HTTP client | Active; generic, less TS-idiomatic than the others |
Why openapi-typescript¶
- Drop-in for the existing
kyclient. It only emits types. The HTTP call sites stay exactly as written today (api.get(API_ME_URL).json<User>()); the only change is whereUsercomes from. None of the other three deliver that — they all generate their own call layer. - Smallest blast radius. One dev dep, one declaration file, no runtime code. If the team later changes its mind, ripping it out is
rm -rf src/types/api && npm uninstall openapi-typescript. - Fast. ~80ms for our 80-route, 71-schema spec. Suitable for a pre-commit or CI drift check without slowing the loop.
- OpenAPI 3.1 / Pydantic 2 native. FastAPI 0.136 emits 3.1; this matches.
Why not the others¶
@hey-api/openapi-tsgenerates a full SDK. Switching fromkyto a generated SDK is a separate, much larger decision; doing it under the banner of "type generation" would conflate two changes. Worth revisiting if/when we want generated mutation hooks.orvalis genuinely excellent when you're already on TanStack Query. We're not — Pinia stores still own data fetching — so its framework integrations would be wasted weight.openapi-generator-clidrags a JRE into the dev toolchain and emits less idiomatic TypeScript than tools written natively in TS. Only justified if we needed cross-language generation (Java/Go/Rust clients in the same repo); we don't.
2. POC implementation¶
Files added¶
| Path | Purpose |
|---|---|
frontend/scripts/gen-api-types.mjs | Node ESM generator: live URL with snapshot fallback |
frontend/scripts/openapi.snapshot.json | Committed snapshot for offline / pre-backend regeneration |
frontend/src/types/api/openapi.d.ts | Generated types, committed for IDE support |
docs/src/implementation-plans/217-openapi-typescript-typegen.md | This document |
Files modified¶
| Path | Change |
|---|---|
frontend/package.json | Add openapi-typescript@=7.13.0 devDependency and gen-api-types script |
frontend/Makefile | Add gen-api-types target wrapping the npm script |
frontend/eslint.config.js | Node-globals for scripts/**; ignore generated src/types/api/openapi.d.ts |
frontend/src/stores/auth.ts | Replace hand-written User with a thin wrapper around components["schemas"]["UserRead"] |
Generator script behaviour¶
scripts/gen-api-types.mjs resolves the schema source in this order:
- Live backend. Fetches
OPENAPI_URL(defaulthttp://localhost:8000/openapi.json) with a 3 s timeout. Used when the backend is up locally and (later) in CI. - Committed snapshot. Falls back to
scripts/openapi.snapshot.jsonwhen the live fetch fails. Keeps the generator usable offline and on contributor machines that don't have the FastAPI stack running.
Either way it shells out to openapi-typescript and writes src/types/api/openapi.d.ts. The snapshot is regenerated by dumping backend/app.main.app.openapi() (no HTTP server required).
Worked example: migrating User¶
The hand-maintained interface in frontend/src/stores/auth.ts was:
interface User {
id: string;
email: string;
display_name?: string;
is_user_test?: boolean;
institutional_id?: string;
roles_raw: Array<{
role: string;
on: { unit?: string; affiliation?: string } | "global";
}>;
permissions?: {
[key: string]: { view?: boolean; edit?: boolean; export?: boolean };
};
}
Replaced with:
import type { FlatUserPermissions } from "src/constant/permissions";
import type { components } from "src/types/api/openapi";
type GeneratedUserRead = components["schemas"]["UserRead"];
type User = Omit<
GeneratedUserRead,
"permissions" | "roles_raw" | "institutional_id"
> & {
permissions?: FlatUserPermissions;
// Normalized to `[]` at the API boundary in `getUser()`, so callers can
// `.map()` without an optional guard.
roles_raw: Array<{
role: string;
on: { unit?: string; affiliation?: string } | "global";
}>;
// `response_model_exclude_none=True` strips this from the wire when null,
// even though the generated type marks it required. Reflect runtime reality.
institutional_id?: string;
};
Why the wrapper (and not the bare generated type):
- The backend's
UserRead.permissionsandUserRead.roles_raware declared withadditionalProperties: true(computed Pydantic fields) and serialize tounknownin the generated schema. The runtime shape is narrower —FlatUserPermissionsand the typedroles_rawarray — and that narrower shape is what permission helpers (hasPermission,hasAnyScopePermission, …) andauthGuard.tsactually consume. Using the bare generated type would pushascasts to every call site. roles_rawandinstitutional_idare also overridden:getUser()normalizesroles_rawto[]so the local type can keep it non-optional, andinstitutional_idis marked optional becauseresponse_model_exclude_none=Trueomits it from the wire when null.- Everything else (
id: number,email: string,display_name,is_user_test,last_login,provider) flows throughOmitso backend changes to those fields immediately surface in TypeScript.
Drift surfaced by this migration¶
The pre-existing hand-typed id: string was wrong. The backend emits UserBase.id: Optional[int] and UserRead.id is integer in the schema. The migration silently fixes this — user.value.id is now number. The only consumer is the display-name fallback in the same store, written String(user.value.id) || '?' so the numeric id renders as a string (the only falsy number is 0, never a real user id).
Verification¶
cd frontend && make type-check— passes.cd frontend && make lint— passes.cd frontend && make gen-api-types— regeneratessrc/types/api/openapi.d.tsfrom the committed snapshot in ~80 ms; output is byte-identical to the committed file (lint stable).
3. POC limitations and follow-ups¶
POC uses a committed snapshot for the generator's input¶
Generating from a committed openapi.snapshot.json is intentional for the POC: it lets reviewers and contributors verify the toolchain without standing up postgres + alembic + uvicorn. The generator does try the live backend first, so the live path is already exercised — it just falls back to the snapshot when nothing is listening.
The follow-up CI integration will:
- Stand up the backend in the same workflow that already runs
make type-check. - Re-run
make gen-api-typesagainst the live/openapi.json. - Fail the build if
git diff --exit-code src/types/api/openapi.d.tsreports a delta — i.e. someone changed a Pydantic schema without regenerating the frontend types.
The snapshot stays in the repo as a fallback for offline contributors and as a sanity reference for what the schema looked like at any given commit.
Migration roadmap (separate PRs)¶
Once this POC merges, hand-maintained API interfaces can be replaced incrementally, one module at a time. Suggested order based on churn-vs-value:
frontend/src/stores/workspace.ts— Unit list, selected unit.frontend/src/stores/backoffice.ts— Backoffice user list, roles, role-assignment payloads.frontend/src/api/audit.ts— Audit log entries (rich nested schemas — biggest drift risk).frontend/src/api/locations.ts,api/modules.ts— Reference data.
Each migration uses the same Omit + override pattern wherever a computed-field or additionalProperties: true shape is involved, or the bare components["schemas"][...] import where the schemas are fully typed.