M-Bus Gateway
← Tilbage til blog
· 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.

Se TanStack Query guide eller FastAPI multi-tenant guide.