· TypeScript· React· FastAPI· OpenAPI· Zod· API· typer· codegen· type safety
TypeScript API-typer i React — generering fra FastAPI OpenAPI
TypeScript API-typer i React: generér typer automatisk fra FastAPI OpenAPI-spec, api.ts mønster, Zod runtime validation og håndtering af paginated responses.
Af M-Bus Gateway
M-Bus Gateway frontend bruger TypeScript-typer der matcher FastAPI's Pydantic-modeller nøjagtigt. Her er mønsteret der eliminerer runtime type-fejl.
Problem: Manuelle typer divergerer fra backend
// PROBLEM: Manuel type i frontend:
interface Settlement {
id: string;
total_amount: number;
status: string; // Backend har: "draft" | "generated" | "sent" | "viewed" | "paid" | "closed"
}
// Backend ændrer "paid" til "payment_received" → ingen TypeScript-fejl, runtime crash
// LØSNING: Generér typer fra FastAPI OpenAPI-spec:
// npx openapi-typescript http://localhost:8000/openapi.json --output src/api-types.ts
OpenAPI → TypeScript med openapi-typescript
# Installer:
npm install -D openapi-typescript
# Generér typer fra kørende FastAPI:
npx openapi-typescript http://localhost:8000/openapi.json \
--output ui/src/api-types.ts \
--immutable-types \
--path-params-as-types
# Eller fra statisk fil (til CI):
npx openapi-typescript server/openapi.json --output ui/src/api-types.ts
// Genereret api-types.ts (uddrag):
export interface components {
schemas: {
SettlementOut: {
id: string;
tenant_id: string;
property_id: string;
status: "draft" | "generated" | "sent" | "viewed" | "paid" | "closed";
total_amount: number;
currency: string;
period_start: string; // ISO date
period_end: string;
pdf_url: string | null;
created_at: string;
};
// ... alle andre schemas
};
}
// Brug i frontend:
type Settlement = components["schemas"]["SettlementOut"];
api.ts mønster med fuld type safety
// ui/src/api.ts
const BASE_URL = "/api/v1";
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem("access_token");
const resp = await fetch(`${BASE_URL}${path}`, {
headers: {
Authorization: `Bearer ${token ?? ""}`,
"Content-Type": "application/json",
},
...options,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({})) as { detail?: string };
throw new ApiError(err.detail ?? `HTTP ${resp.status}`, resp.status);
}
return resp.json() as Promise<T>;
}
// Typed API-objekter:
export const portalSettlements = {
list: (propertyId?: string) =>
apiFetch<Settlement[]>(
propertyId ? `/settlements?property_id=${propertyId}` : "/settlements"
),
get: (id: string) =>
apiFetch<Settlement>(`/settlements/${id}`),
generate: (id: string) =>
apiFetch<{ task_id: string }>(`/settlements/${id}/generate`, {
method: "POST",
}),
};
// Custom error class med HTTP status:
export class ApiError extends Error {
constructor(message: string, public readonly status: number) {
super(message);
this.name = "ApiError";
}
}
Zod: Runtime validation ved API-grænser
// Zod validerer at API-svaret matcher forventet schema:
import { z } from "zod";
const SettlementSchema = z.object({
id: z.string().uuid(),
status: z.enum(["draft", "generated", "sent", "viewed", "paid", "closed"]),
total_amount: z.number(),
currency: z.string().length(3),
period_start: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
pdf_url: z.string().url().nullable(),
});
type Settlement = z.infer<typeof SettlementSchema>;
async function getSettlement(id: string): Promise<Settlement> {
const raw = await apiFetch<unknown>(`/settlements/${id}`);
return SettlementSchema.parse(raw); // Kaster ZodError ved uventet format
}
Pagineret API: Generisk type
// FastAPI Pagination-response:
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
size: number;
pages: number;
}
// TanStack Query med infinite scroll:
import { useInfiniteQuery } from "@tanstack/react-query";
function useSettlementList(propertyId: string) {
return useInfiniteQuery({
queryKey: ["settlements", "list", propertyId],
queryFn: ({ pageParam = 1 }) =>
apiFetch<PaginatedResponse<Settlement>>(
`/settlements?property_id=${propertyId}&page=${pageParam}&size=20`
),
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined,
initialPageParam: 1,
});
}
// Brug i komponent:
function SettlementList({ propertyId }: { propertyId: string }) {
const { data, fetchNextPage, hasNextPage } = useSettlementList(propertyId);
const settlements = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div>
{settlements.map((s) => <SettlementRow key={s.id} settlement={s} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Indlæs flere</button>
)}
</div>
);
}
Discriminated unions: Polymorfiske API-svar
// FastAPI returnerer forskellige objekt-typer afhængigt af context:
type MeterReading =
| { type: "hca"; value_hca: number; set_date: string }
| { type: "heat"; value_kwh: number; flow_temp_c: number; return_temp_c: number }
| { type: "water"; value_m3: number; flow_rate_l_h: number };
function ReadingDisplay({ reading }: { reading: MeterReading }) {
switch (reading.type) {
case "hca":
return <span>{reading.value_hca} HCA</span>;
case "heat":
return <span>{reading.value_kwh.toFixed(2)} kWh</span>;
case "water":
return <span>{reading.value_m3.toFixed(3)} m³</span>;
}
}
CI-integration: Typetjek mod live API
# .github/workflows/ci.yml
- name: Generate TypeScript types from OpenAPI
run: |
# Start FastAPI i test-mode:
cd server && python -m uvicorn src.main:app --port 8000 &
sleep 5
# Generér types:
cd ui && npx openapi-typescript http://localhost:8000/openapi.json \
--output src/api-types.ts
# Kør TypeScript-kompilering (ingen emit — kun typetjek):
npx tsc --noEmit
Konklusion
Automatisk TypeScript-type-generering fra FastAPI OpenAPI-spec eliminerer manuel synkronisering og sikrer at frontend altid matcher backend. Zod giver runtime validation ved API-grænser — særligt nyttigt ved external API-integrationer. Det kræver én CI-step, men eliminerer en hel kategori af runtime-fejl.