The Python–TypeScript Contract

Part of The Coercion Saga — making AI write quality code.

The uncomfortable situation

Backend tests pass. Frontend tests pass. Each side works in isolation. But do they work together?

Backend changes API. Frontend breaks. Nobody notices until production.

You rename a field from userName to username. Backend tests pass. Frontend tests pass—they mock the API anyway. Everything is green. You deploy. Production breaks because frontend expects userName but backend sends username.

This happens more often than anyone wants to admit.

The Problem With Mocks

Frontend tests mock the API. They have to—you can’t run the real backend in every test. But mocks lie. They return what you told them to return, not what the backend actually returns.

Backend changes. Mocks don’t. Tests pass. Production fails.

The solution: a single source of truth that both sides must follow.

OpenAPI as the Contract

FastAPI generates OpenAPI specs automatically from your type hints:

@router.post("/users", response_model=UserResponse)
async def create_user(user: UserCreate) -> User:
    ...

No extra work. Your types become the spec. The spec becomes the contract.

{
  "paths": {
    "https://dev.to/users": {
      "post": {
        "requestBody": { "$ref": "#/components/schemas/UserCreate" },
        "responses": {
          "200": { "$ref": "#/components/schemas/UserResponse" }
        }
      }
    }
  }
}

This file lives in the repo. Both sides must match it.

Orval: Generated TypeScript Client

Orval reads the OpenAPI spec and generates TypeScript. Not just types—full API clients with the HTTP layer baked in.

Configuration lives in orval.config.ts:

export default defineConfig({
  api: {
    input: '../shared/openapi.json',
    output: {
      target: './src/api/generated/endpoints.ts',
      schemas: './src/api/generated/models',
      client: 'react-query',
      mode: 'tags-split',
    },
  },
});

Run npx orval and you get:

// Generated - don't edit
export const useCreateUser = (
  options?: UseMutationOptions<UserResponse, Error, UserCreate>
) => {
  return useMutation({
    mutationFn: (userCreate: UserCreate) => createUser(userCreate),
    ...options,
  });
};

export const createUser = (userCreate: UserCreate): Promise<UserResponse> => {
  return customFetch<UserResponse>({
    url: '/users',
    method: 'POST',
    data: userCreate,
  });
};

React Query hooks out of the box. Mutations, queries, cache invalidation patterns. All typed.

You can also generate:

  • Axios clientsclient: 'axios'
  • Fetch clientsclient: 'fetch'
  • SWR hooksclient: 'swr'
  • Zod schemas — for runtime validation on top of compile-time types

The mode: 'tags-split' option generates one file per API tag. Clean separation. users.ts, products.ts, orders.ts. Import only what you need.

No manual API client. No type drift. The types always match the spec because they’re generated from it.

Backend renames userName to username? The generated types change. TypeScript screams at every place in the frontend that still uses the old name. You fix it before it ships.

The Workflow

  1. Backend changes the API
  2. OpenAPI spec updates (FastAPI does this automatically)
  3. Run npm run codegen in frontend
  4. Generated types update
  5. TypeScript catches breaking changes
  6. Fix the frontend
  7. CI validates everything matches

No coordination meetings. No “hey did you update the frontend?” The types enforce the contract.

The Gate

Two checks. Backend spec must match reality. Frontend types must match spec.

validate:openapi:
  stage: contract
  image: python:3.12-slim
  services:
    - name: postgres:16
      alias: db
  variables:
    DATABASE_URL: postgresql://postgres:postgres@db:5432/test
  before_script:
    - pip install uv && cd backend && uv sync --frozen
  script:
    - cd backend && uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &
    - sleep 5
    - curl -s http://localhost:8000/openapi.json > /tmp/live-spec.json
    - diff shared/openapi.json /tmp/live-spec.json
  allow_failure: false

validate:codegen:
  stage: contract
  image: node:lts-slim
  before_script:
    - cd frontend && npm ci --prefer-offline
  script:
    - npm run codegen
    - git diff --exit-code src/api/generated
  allow_failure: false

validate:openapi — Starts the backend, fetches the live spec, compares to committed spec. Different? CI fails. Someone changed the API without updating the spec.

validate:codegen — Regenerates the TypeScript client, checks if it differs from what’s committed. Different? CI fails. Someone updated the spec without regenerating the client.

Copy, paste, adapt. It works.

The Point

Backend and frontend speak different languages. Python here, TypeScript there. Without a contract, they drift apart. Small changes accumulate. Production breaks.

OpenAPI is the contract. Orval generates the types. CI validates the match.

Breaking changes are impossible to miss. Not “unlikely.” Impossible. The pipeline catches them before they ship.

That’s the deal.

Next up: [E2E Tests] -coming soon- — The contract is enforced. Now test the real thing: a browser hitting the full stack.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

AI engine optimization audit: How to audit your content for AI search engines

Next Post

How Law Firms Can Use Social Media for Content Marketing

Related Posts