Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4B.4 — Cross-Language Type Contracts: OpenAPI as the Universal Bridge

For .NET engineers who know: Swashbuckle/NSwag for generating OpenAPI specs from ASP.NET Core, and NSwag/Swagger Codegen for generating C# clients from specs You’ll learn: How to use OpenAPI as the type-safety bridge across TypeScript, C#, and Python services — including the complete CI/CD pipeline that catches contract drift before it reaches production Time: 15-20 minutes


The .NET Way (What You Already Know)

In a pure .NET system, type safety across the client-server boundary is a solved problem. Swashbuckle generates an OpenAPI spec from your ASP.NET Core controllers. NSwag reads that spec and generates a typed C# client. The chain works because both ends are .NET.

// ASP.NET Core — controller with explicit response types
[HttpGet("{id}")]
[ProducesResponseType(typeof(OrderDto), 200)]
[ProducesResponseType(typeof(ProblemDetails), 404)]
public async Task<IActionResult> GetOrder([FromRoute] Guid id)
{
    var order = await _orderService.GetByIdAsync(id);
    return order is null ? NotFound() : Ok(order);
}

// DTO — this is the contract that flows to generated clients
public record OrderDto(
    Guid Id,
    string CustomerName,
    decimal Total,
    OrderStatus Status,
    DateTime CreatedAt
);
# CI pipeline generates the spec and publishes it as an artifact
dotnet swagger tofile --output openapi.json bin/Release/net9.0/MyApi.dll v1

# A consuming service runs NSwag to generate a typed client
# nswag run nswag.json
# → Generates MyApiClient.cs with full type safety

The key property: a breaking change in OrderDto (renaming CustomerName to Customer, removing a field, changing a type) causes a compile error in the consuming C# client. The type system finds the break before it reaches production.

In a polyglot system — TypeScript frontend, C# backend, Python ML service — you lose this guarantee unless you deliberately recreate it. The TypeScript compiler does not know what your C# OrderDto looks like. The Python Pydantic model is invisible to TypeScript. Without explicit contract machinery, the boundaries between languages are runtime minefields.


The TypeScript Stack Way

The Core Problem: Types Don’t Cross Language Boundaries

tRPC is the gold standard for TypeScript type safety — a change to a NestJS procedure’s return type causes a TypeScript error in the Next.js component that calls it, in the same monorepo, without code generation. But tRPC is TypeScript-to-TypeScript only. The moment a non-TypeScript service enters the picture, tRPC cannot help you.

TypeScript (NestJS) ──tRPC──→ TypeScript (Next.js)  ✓ Full type inference
C# (ASP.NET Core)   ──???──→ TypeScript (Next.js)   ✗ No type flow
Python (FastAPI)    ──???──→ TypeScript (Next.js)   ✗ No type flow

The solution is not to invent a new system. OpenAPI already exists. Every serious backend framework generates it. Every frontend code generation tool consumes it. The work is building the pipeline that keeps specs current and makes contract drift fail the build rather than silently break production.

How Each Backend Generates OpenAPI

Each language has native tooling that generates OpenAPI from its own type system. The output format (JSON or YAML, OpenAPI 3.x) is identical regardless of source language.

ASP.NET Core: Swashbuckle or NSwag

Swashbuckle is the default choice for ASP.NET Core OpenAPI generation. NSwag is the alternative with richer code generation features. Both read your controller attributes, ProducesResponseType declarations, and XML documentation comments.

// Program.cs — configure Swashbuckle
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Orders API",
        Version = "v1",
        Description = "Order management service"
    });

    // Include XML documentation for richer OpenAPI descriptions
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    options.IncludeXmlComments(xmlPath);

    // Configure enum serialization — critical for TypeScript consumers
    options.UseAllOfToExtendReferenceSchemas();
});

// IMPORTANT: Configure JSON to serialize enums as strings
builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
    // Without this, enums serialize as integers in the OpenAPI spec
    // TypeScript consumers get number literals instead of named unions
});
<!-- .csproj — enable XML documentation generation -->
<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <!-- Suppress "missing XML comment" warnings on internal types -->
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
# Generate spec as part of CI — add this step after dotnet build
dotnet swagger tofile \
  --output ./openapi/orders-api.json \
  bin/Release/net9.0/OrdersApi.dll \
  v1

For .NET 9+, consider the built-in Microsoft.AspNetCore.OpenApi package as an alternative to Swashbuckle, which is now maintained directly by the ASP.NET Core team.

FastAPI: Automatic from Pydantic Models

FastAPI generates OpenAPI automatically from Pydantic model definitions and route signatures. There is no separate generation step — the spec is available at /openapi.json while the server is running.

# api/models/order.py
from pydantic import BaseModel
from enum import Enum
from datetime import datetime
from decimal import Decimal
import uuid

class OrderStatus(str, Enum):
    # Using str Enum so FastAPI serializes as strings, not integers
    # This aligns with the .NET JsonStringEnumConverter behavior above
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"
    DELIVERED = "delivered"

class OrderDto(BaseModel):
    id: uuid.UUID
    customer_name: str
    total: Decimal
    status: OrderStatus
    created_at: datetime

    model_config = {
        # Use camelCase in JSON output to match TypeScript conventions
        "populate_by_name": True,
        "alias_generator": lambda s: "".join(
            w.capitalize() if i > 0 else w
            for i, w in enumerate(s.split("_"))
        )
    }

# api/routes/orders.py
from fastapi import APIRouter, HTTPException
from .models.order import OrderDto

router = APIRouter(prefix="/orders", tags=["orders"])

@router.get("/{order_id}", response_model=OrderDto)
async def get_order(order_id: uuid.UUID) -> OrderDto:
    """Retrieve an order by ID."""
    # FastAPI generates OpenAPI from the response_model, return type hint,
    # and the docstring — no additional configuration needed
    order = await order_service.get_by_id(order_id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    return order
# Export the spec without a running server — useful in CI
# Using the fastapi CLI or a script that creates the app without starting uvicorn

python -c "
import json
from api.main import app
with open('openapi/ml-api.json', 'w') as f:
    json.dump(app.openapi(), f, indent=2)
"

NestJS: @nestjs/swagger from Decorators

NestJS generates OpenAPI from @nestjs/swagger decorators on controllers and DTOs. Unlike FastAPI, it does not auto-generate from type information alone — you must add the decorators.

// orders/dto/order.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export enum OrderStatus {
  PENDING = "pending",
  CONFIRMED = "confirmed",
  SHIPPED = "shipped",
  DELIVERED = "delivered",
}

export class OrderDto {
  @ApiProperty({ format: "uuid" })
  id: string;

  @ApiProperty()
  customerName: string;

  @ApiProperty({ type: Number, format: "double" })
  total: number;

  @ApiProperty({ enum: OrderStatus })
  status: OrderStatus;

  @ApiProperty({ format: "date-time" })
  createdAt: Date;
}

// orders/orders.controller.ts
import { ApiTags, ApiOkResponse, ApiNotFoundResponse } from "@nestjs/swagger";

@ApiTags("orders")
@Controller("orders")
export class OrdersController {
  @Get(":id")
  @ApiOkResponse({ type: OrderDto })
  @ApiNotFoundResponse({ description: "Order not found" })
  async findOne(@Param("id", ParseUUIDPipe) id: string): Promise<OrderDto> {
    const order = await this.ordersService.findById(id);
    if (!order) throw new NotFoundException(`Order ${id} not found`);
    return order;
  }
}
// main.ts — configure Swagger generation
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { writeFileSync } from "fs";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle("BFF API")
    .setVersion("1.0")
    .addBearerAuth()
    .build();

  const document = SwaggerModule.createDocument(app, config);

  // Write spec to disk for CI artifact upload
  writeFileSync("./openapi/bff-api.json", JSON.stringify(document, null, 2));

  SwaggerModule.setup("api/docs", app, document);
  await app.listen(3000);
}
# In CI — run the app briefly to generate the spec, then exit
# Or use a dedicated script that imports the app module without starting Kestrel
ts-node scripts/generate-openapi.ts
# → writes openapi/bff-api.json

Frontend Type Generation: Two Tools, Different Trade-offs

Once you have OpenAPI specs, you have two primary tools for generating TypeScript types from them.

openapi-typescript: Types Only

openapi-typescript generates TypeScript type definitions from an OpenAPI spec. It produces no runtime code — just types. You bring your own HTTP client.

pnpm add -D openapi-typescript
// package.json scripts
{
  "scripts": {
    "generate:types:dotnet": "openapi-typescript openapi/orders-api.json -o src/types/orders-api.d.ts",
    "generate:types:python": "openapi-typescript openapi/ml-api.json -o src/types/ml-api.d.ts",
    "generate:types": "pnpm run generate:types:dotnet && pnpm run generate:types:python"
  }
}
// Using the generated types with openapi-fetch
import createClient from "openapi-fetch";
import type { paths } from "@/types/orders-api";

const ordersClient = createClient<paths>({
  baseUrl: process.env.DOTNET_API_URL,
});

// Fully typed — the input and output shapes are inferred from the spec
const { data, error } = await ordersClient.GET("/orders/{id}", {
  params: { path: { id: "some-uuid" } },
});

// data is typed as the 200 response schema
// error is typed as the error response schemas

orval: Types + TanStack Query Hooks

orval goes further than openapi-typescript — it generates TanStack Query hooks with full types, error handling, and cache key management. This is the recommended tool when you want React Query integration without hand-writing the hooks.

pnpm add -D orval
// orval.config.ts — configure generation for each API
import { defineConfig } from "orval";

export default defineConfig({
  // .NET API — generates TanStack Query hooks
  ordersApi: {
    input: {
      target: "./openapi/orders-api.json",
    },
    output: {
      mode: "tags-split",    // one file per OpenAPI tag
      target: "./src/api/orders",
      schemas: "./src/types/orders",
      client: "react-query",
      httpClient: "fetch",
      override: {
        mutator: {
          path: "./src/lib/api-client.ts",  // custom fetch wrapper with auth
          name: "customFetch",
        },
      },
    },
  },

  // Python FastAPI — generates TanStack Query hooks
  mlApi: {
    input: {
      target: "./openapi/ml-api.json",
    },
    output: {
      mode: "tags-split",
      target: "./src/api/ml",
      schemas: "./src/types/ml",
      client: "react-query",
      httpClient: "fetch",
      override: {
        mutator: {
          path: "./src/lib/python-api-client.ts",
          name: "pythonApiFetch",
        },
      },
    },
  },
});
// src/lib/api-client.ts — the custom fetch mutator injected into generated hooks
// This is where you add auth headers, base URL, and error handling

export const customFetch = async <T>(
  url: string,
  options: RequestInit,
): Promise<T> => {
  const { getToken } = auth(); // Clerk auth helper
  const token = await getToken();

  const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${url}`, {
    ...options,
    headers: {
      ...options.headers,
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
  });

  if (!response.ok) {
    // Normalize error shapes from different backends
    const errorBody = await response.json().catch(() => ({}));
    throw new ApiError(response.status, errorBody);
  }

  return response.json();
};
// Generated hook usage — fully typed, no hand-written code
import { useGetOrderById } from "@/api/orders/orders";

function OrderPage({ orderId }: { orderId: string }) {
  // Return type is inferred from the OpenAPI spec
  const { data: order, isLoading, error } = useGetOrderById(orderId);

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorDisplay error={error} />;

  // order is typed as OrderDto from the OpenAPI spec
  return <div>{order.customerName}</div>;
}

The CI/CD Pipeline

The pipeline is the mechanism that makes contract safety automatic. Without it, developers forget to regenerate types, specs drift from reality, and the type system gives false confidence.

The Architecture

graph TD
    subgraph BackendCI["Backend CI (per service)"]
        B1["build"]
        B2["test"]
        B3["generate OpenAPI spec"]
        B4["upload spec artifact"]
        STORE["GitHub Releases / S3 / Artifact"]
        B1 --> B2 --> B3 --> B4 --> STORE
    end

    subgraph FrontendCI["Frontend CI"]
        F1["download specs"]
        F2["generate TS types"]
        F3["type-check"]
        F4["build"]
        FAIL["FAIL BUILD — breaking change detected"]
        F1 --> F2 --> F3 --> F4
        F3 -->|on type-check failure| FAIL
    end

    STORE -->|on spec publish| F1

Complete GitHub Actions Workflows

Backend: ASP.NET Core

# .github/workflows/dotnet-api.yml
name: .NET API CI

on:
  push:
    branches: [main]
    paths: ["services/orders-api/**"]
  pull_request:
    paths: ["services/orders-api/**"]

jobs:
  build-and-publish-spec:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/orders-api

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release

      - name: Generate OpenAPI spec
        run: |
          dotnet tool restore
          dotnet swagger tofile \
            --output ../../openapi/orders-api.json \
            bin/Release/net9.0/OrdersApi.dll \
            v1

      - name: Detect breaking changes
        if: github.event_name == 'pull_request'
        uses: oasdiff/oasdiff-action@main
        with:
          base: "https://raw.githubusercontent.com/${{ github.repository }}/main/openapi/orders-api.json"
          revision: "openapi/orders-api.json"
          fail-on-diff: "ERR"  # Fail CI on breaking changes (non-breaking changes are warnings)

      - name: Upload OpenAPI spec artifact
        uses: actions/upload-artifact@v4
        with:
          name: orders-api-spec
          path: openapi/orders-api.json
          retention-days: 30

      # On main branch, commit the updated spec back to the repo
      # This keeps the spec in version control alongside the code
      - name: Commit updated spec
        if: github.ref == 'refs/heads/main'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add ../../openapi/orders-api.json
          git diff --staged --quiet || git commit -m "chore: update orders-api OpenAPI spec [skip ci]"
          git push

Backend: FastAPI (Python)

# .github/workflows/python-api.yml
name: Python ML API CI

on:
  push:
    branches: [main]
    paths: ["services/ml-api/**"]
  pull_request:
    paths: ["services/ml-api/**"]

jobs:
  build-and-publish-spec:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/ml-api

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/ -v

      - name: Generate OpenAPI spec
        run: |
          python -c "
          import json
          from api.main import app
          spec = app.openapi()
          with open('../../openapi/ml-api.json', 'w') as f:
              json.dump(spec, f, indent=2)
          "

      - name: Detect breaking changes
        if: github.event_name == 'pull_request'
        uses: oasdiff/oasdiff-action@main
        with:
          base: "https://raw.githubusercontent.com/${{ github.repository }}/main/openapi/ml-api.json"
          revision: "openapi/ml-api.json"
          fail-on-diff: "ERR"

      - name: Upload OpenAPI spec artifact
        uses: actions/upload-artifact@v4
        with:
          name: ml-api-spec
          path: openapi/ml-api.json
          retention-days: 30

      - name: Commit updated spec
        if: github.ref == 'refs/heads/main'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add ../../openapi/ml-api.json
          git diff --staged --quiet || git commit -m "chore: update ml-api OpenAPI spec [skip ci]" && git push

Frontend: Type Generation and Validation

# .github/workflows/frontend.yml
name: Frontend CI

on:
  push:
    branches: [main]
    paths: ["apps/web/**", "openapi/**"]
  pull_request:
    paths: ["apps/web/**", "openapi/**"]

jobs:
  type-check-and-build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Setup pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 9

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Regenerate TypeScript types from the OpenAPI specs in the repo
      # If specs have changed (from backend CI commits), this picks up the changes
      - name: Generate TypeScript types from OpenAPI specs
        run: pnpm run generate:types
        working-directory: apps/web

      # If types were regenerated AND they differ from what's committed,
      # that means a backend changed its spec without the frontend updating types.
      # Fail the build — the developer needs to run generate:types locally and commit.
      - name: Check generated types are up to date
        run: |
          git diff --exit-code apps/web/src/types/ apps/web/src/api/
          if [ $? -ne 0 ]; then
            echo "Generated types are out of date."
            echo "Run 'pnpm run generate:types' in apps/web and commit the changes."
            exit 1
          fi

      - name: TypeScript type check
        run: pnpm tsc --noEmit
        working-directory: apps/web
        # A breaking change in a backend OpenAPI spec will fail here:
        # the generated types changed, and existing code that depended on the
        # old types will now have TypeScript errors.

      - name: Lint
        run: pnpm lint
        working-directory: apps/web

      - name: Test
        run: pnpm test --run
        working-directory: apps/web

      - name: Build
        run: pnpm build
        working-directory: apps/web

Zod as a Runtime Safety Net

Generated TypeScript types from OpenAPI specs are compile-time constructs. They catch shape mismatches during development and CI. But at runtime, the type is erased — TypeScript cannot validate that the actual HTTP response matches the generated type.

This matters because:

  • The .NET server might serialize null where the spec says the field is required
  • Date fields might serialize as strings in one format in dev and another in production
  • A backend deploy might lag behind the spec, sending an old response shape

Zod closes this gap. You define a Zod schema that mirrors the generated type and parse every API response through it. If the runtime shape does not match, you get an explicit error — not a silent undefined that surfaces as a UI bug three screens later.

// src/lib/validated-fetch.ts
import { z, ZodSchema } from "zod";

export async function validatedFetch<T>(
  schema: ZodSchema<T>,
  url: string,
  options?: RequestInit,
): Promise<T> {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new ApiError(response.status, await response.text());
  }

  const json = await response.json();

  // Parse and validate — throws ZodError with field-level details on failure
  const result = schema.safeParse(json);

  if (!result.success) {
    // Log the actual response and the validation failure for debugging
    console.error("API response failed schema validation", {
      url,
      actualResponse: json,
      errors: result.error.flatten(),
    });
    // In development, throw hard. In production, you may want to degrade gracefully.
    throw new ContractViolationError(url, result.error);
  }

  return result.data;
}
// src/schemas/order.schema.ts
// Zod schema that mirrors the generated OpenAPI type
// Keep this in sync with the generated types — or generate it from the spec

import { z } from "zod";

export const OrderStatusSchema = z.enum([
  "pending",
  "confirmed",
  "shipped",
  "delivered",
]);

export const OrderSchema = z.object({
  id: z.string().uuid(),
  customerName: z.string().min(1),
  total: z.number().positive(),
  status: OrderStatusSchema,
  // Zod handles the string-to-Date transform that TypeScript types do not
  createdAt: z.string().datetime().transform((s) => new Date(s)),
});

export type Order = z.infer<typeof OrderSchema>;
// This type is equivalent to the generated OpenAPI type — use either in code
// Usage in a Server Component — Zod validates the .NET response at the boundary
async function getOrder(id: string): Promise<Order> {
  const { getToken } = auth();
  const token = await getToken();

  return validatedFetch(OrderSchema, `${process.env.DOTNET_API_URL}/orders/${id}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
}

The Zod validation at the boundary is the runtime equivalent of contract testing. It does not replace type generation — it complements it. Generated types give you compile-time safety; Zod gives you runtime safety at the exact point where you cannot trust the other system.


Auth Token Forwarding Across Service Boundaries

Clerk issues a JWT to the frontend user. That JWT must be forwarded to each backend service for authentication. The forwarding mechanism differs depending on where the call is made.

// src/lib/server-fetch.ts
// Server Components run on the Next.js server — the Clerk session is available
// but the token must be explicitly forwarded; it does not travel as a cookie

import { auth } from "@clerk/nextjs/server";

export async function serverFetch<T>(
  schema: ZodSchema<T>,
  url: string,
  options?: RequestInit,
): Promise<T> {
  const { getToken } = auth();
  // Request a short-lived token for the target audience (optional but more secure)
  const token = await getToken({ template: "api-token" });

  return validatedFetch(schema, url, {
    ...options,
    headers: {
      ...options?.headers,
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    // Disable Next.js fetch caching for auth-required requests
    cache: "no-store",
  });
}
// ASP.NET Core — validate the Clerk JWT
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Clerk's JWKS endpoint — tokens are validated against Clerk's public keys
        options.Authority = $"https://{builder.Configuration["Clerk:Domain"]}";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false, // Clerk JWTs do not include an audience by default
            ValidateIssuer = true,
        };
    });
# FastAPI — validate the Clerk JWT using python-jose
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer
import httpx

security = HTTPBearer()

async def get_current_user(credentials = Depends(security)):
    try:
        # Fetch Clerk's JWKS to validate the token
        async with httpx.AsyncClient() as client:
            jwks = await client.get(f"https://{CLERK_DOMAIN}/.well-known/jwks.json")

        payload = jwt.decode(
            credentials.credentials,
            jwks.json(),
            algorithms=["RS256"],
            options={"verify_aud": False}
        )
        return payload
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials"
        )

@router.get("/orders/{order_id}")
async def get_order(
    order_id: uuid.UUID,
    user = Depends(get_current_user)  # JWT validation on every protected route
):
    # ...

Versioning Strategy

When you have multiple consumers of an OpenAPI spec (different frontend versions, different services), breaking changes must be coordinated. Three strategies, in order of complexity:

URL versioning — the simplest and most explicit:

/api/v1/orders/{id}   ← current stable version
/api/v2/orders/{id}   ← new version with breaking changes

Both versions are active simultaneously. The frontend migrates to v2 on its own schedule. The v1 spec and v2 spec are separate files: orders-api-v1.json and orders-api-v2.json.

Spec file versioning — commit the spec to version control with semantic versioning:

# In the OpenAPI spec root
openapi/
  orders-api.json          ← latest
  orders-api-1.2.0.json    ← pinned versions for consumers that cannot update
  ml-api.json

Frontend CI specifies which version to use:

// package.json — pin to a specific spec version for reproducible builds
{
  "scripts": {
    "generate:types:dotnet": "openapi-typescript openapi/orders-api-1.2.0.json -o src/types/orders-api.d.ts"
  }
}

Breaking change detection in CI — using oasdiff (already shown in the GitHub Actions workflows above), the CI pipeline fails when a PR introduces a breaking change to a published spec. Breaking changes are defined by the OpenAPI specification: removing a required field, changing a field’s type, removing an endpoint, changing a required query parameter.

# Local breaking change check before pushing
npx @oasdiff/oasdiff breaking \
  openapi/orders-api.json \
  openapi/orders-api-new.json
# Outputs: list of breaking changes with severity

Key Differences

Aspect.NET-only systemPolyglot with OpenAPI bridge
Type sharingCompile-time (shared C# assemblies)Contract-based (OpenAPI spec → generated types)
Breaking change detectionCompile error immediatelyCI pipeline failure (one build cycle lag)
Runtime validation.NET type system validates deserializationZod required for runtime safety
Auth propagationWindows Auth, ASP.NET Identity cookies — automaticJWT must be explicitly forwarded per request
Enum serializationInteger by default (configure JsonStringEnumConverter)String required for TypeScript union type generation
Date handlingDateTime serializes as ISO 8601TypeScript Date requires explicit Zod transform
Null vs. undefinedC# null → JSON nullTypeScript distinguishes null from undefined; Zod handles the mapping
Code generation cadenceOne-time setup with NSwagPer-backend-change; CI automation required

Gotchas for .NET Engineers

Gotcha 1: Enum serialization must be configured explicitly — on every backend

TypeScript’s code generation tools produce string union types from OpenAPI enum definitions: "pending" | "confirmed" | "shipped" | "delivered". This works correctly only when the backend serializes enums as strings.

ASP.NET Core serializes enums as integers by default. Without JsonStringEnumConverter, your OrderStatus.Confirmed becomes 1 in the JSON response. The generated TypeScript type says "confirmed" — you get a silent mismatch that produces undefined status values in your UI.

// Program.cs — REQUIRED for correct TypeScript enum generation
builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

// Also annotate the enum to ensure Swashbuckle generates string values in the spec
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered }

FastAPI with str Enum (as shown above) handles this correctly by default. NestJS with TypeScript enums generates strings naturally. The problem is specifically .NET with default configuration.

Gotcha 2: Date fields serialize as strings — Zod must perform the transform

ASP.NET Core serializes DateTime as an ISO 8601 string: "2026-02-18T14:30:00Z". FastAPI with Pydantic serializes datetime similarly. The OpenAPI spec declares these as type: string, format: date-time.

The generated TypeScript type from openapi-typescript for a date-time field is string, not Date. If you want a Date object, you must transform it explicitly.

// Without Zod — generated type is `string`
const order = await getOrder(id);
order.createdAt;  // string — "2026-02-18T14:30:00Z"
order.createdAt.toLocaleDateString(); // TypeError: not a function

// With Zod transform — you get a Date object
const OrderSchema = z.object({
  createdAt: z.string().datetime().transform((s) => new Date(s)),
});
const order = OrderSchema.parse(rawResponse);
order.createdAt.toLocaleDateString(); // Works

The alternative: keep dates as ISO 8601 strings throughout your application and only convert to Date at the display layer using date-fns or Intl.DateTimeFormat. This approach has fewer surprises — the string representation is what the API actually sends, and you never have a gap between what TypeScript thinks the type is and what exists at runtime.

Gotcha 3: The spec in your repo may be stale

When you commit the OpenAPI spec to version control (as shown in the CI workflows), there is a window between when the backend changes and when the CI commits the updated spec. A developer who pulls main after a backend merge but before the CI spec-update commit will have a stale spec locally.

The mitigation is the check generated types are up to date step in the frontend CI workflow. But locally, developers need discipline: pnpm run generate:types before running type-check or starting the dev server after pulling changes.

# Add this to your onboarding docs and your local git hook
# .husky/post-merge (runs after git pull / git merge)
#!/usr/bin/env sh
pnpm run generate:types --if-present

A stronger mitigation: instead of committing specs to the repo, publish them as GitHub Release assets or to an artifact store. The frontend CI downloads the latest spec from the artifact store at build time, ensuring it always regenerates from the current spec. This eliminates the “stale spec in repo” problem but adds a network dependency in CI.

Gotcha 4: orval generates a lot of files — treat them as build artifacts

Running pnpm run generate:types regenerates dozens of files in src/api/ and src/types/. These files are entirely derived from the OpenAPI spec — they have no handwritten content. Two schools of thought on whether to commit them:

Commit generated files: Simpler local development — no generation step before running. Diffs in PRs show exactly what changed. But: generated file diffs pollute PR reviews, and merge conflicts in generated files are painful.

Gitignore generated files: Cleaner PRs, no merge conflicts. But: every developer must run pnpm run generate:types before starting work, and the CI must regenerate before type-checking.

The recommended approach: commit the generated files, but configure your git diff tool to collapse them. In GitHub, the generated files are flagged with # Generated by orval header comments — future GitHub features will likely hide them from PR diffs automatically.

# .gitattributes — hint to GitHub that these are generated
src/api/**/*.ts linguist-generated=true
src/types/**/*.ts linguist-generated=true

Gotcha 5: null from C# is not the same as undefined in TypeScript

When ASP.NET Core returns a nullable field as null in JSON, the generated TypeScript type marks the field as T | null. In TypeScript, null and undefined are distinct. A property that is null is present with a null value; a property that is undefined is absent from the object.

This creates friction with optional chaining and nullish coalescing:

// Generated type from a nullable C# string field
interface Order {
  notes: string | null;  // Can be null, but NOT undefined — it's always present
}

const order = getOrder();

// This works correctly:
const hasNotes = order.notes !== null;

// This is a common mistake from .NET engineers:
const hasNotes2 = order.notes != null;  // True — but == also catches undefined
// In TypeScript, == null matches both null AND undefined
// In this case it's fine because notes is always present, but the intent is unclear

// Zod can normalize the shape if your app prefers undefined:
const OrderSchema = z.object({
  notes: z.string().nullable().transform((v) => v ?? undefined),
  // Now notes is string | undefined — more idiomatic TypeScript
});

Hands-On Exercise

This exercise sets up the complete type contract pipeline for a polyglot system with one .NET backend and one TypeScript frontend.

What you’ll build: The OpenAPI generation step for an ASP.NET Core project, and the type generation step for a Next.js frontend that consumes it.

Prerequisites: An existing ASP.NET Core Web API project and a Next.js project (or use a test scaffold).

Step 1: Add Swashbuckle to your .NET project

dotnet add package Swashbuckle.AspNetCore
dotnet add package Swashbuckle.AspNetCore.Cli
dotnet tool install --global Swashbuckle.AspNetCore.Cli
// Program.cs — add these lines
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

var app = builder.Build();
app.UseSwagger();

Step 2: Generate the spec from your .NET project

dotnet build --configuration Release
swagger tofile --output openapi.json bin/Release/net9.0/YourApi.dll v1
cat openapi.json | head -30
# Verify the spec looks correct — check enum values, field names, response shapes

Step 3: Install type generation tools in your Next.js project

cd apps/web
pnpm add -D openapi-typescript orval

Step 4: Configure orval

Create apps/web/orval.config.ts with the configuration shown earlier in this article, pointing input.target at your openapi.json file.

Step 5: Generate types and verify

pnpm orval
# Inspect the generated files:
ls src/api/
ls src/types/
# Open one generated hook — verify the return types match your DTO shape

Step 6: Use a generated hook in a component

Replace any existing hand-written fetch call with a generated hook. Run the TypeScript compiler and verify no errors:

pnpm tsc --noEmit

Step 7: Break the contract deliberately

In your .NET project, rename a field in a DTO (for example, CustomerName to Customer). Rebuild and regenerate the spec:

dotnet build && swagger tofile --output openapi.json bin/Release/net9.0/YourApi.dll v1

In your Next.js project, regenerate the types:

pnpm orval
pnpm tsc --noEmit
# You should see TypeScript errors where you reference order.customerName
# The type system found the breaking change

This is the experience you want your CI pipeline to automate: any backend contract break surfaces as a build failure in the frontend.


Quick Reference

Tool Selection

NeedToolNotes
Types only from OpenAPI specopenapi-typescriptLightest; bring your own HTTP client
Types + TanStack Query hooksorvalRecommended; generates everything
Typed fetch client (lighter than orval)openapi-fetchPairs with openapi-typescript
Breaking change detectionoasdiffUse in CI on PR branches
Runtime response validationzodComplements generated types; not a replacement

Backend OpenAPI Generation Commands

BackendGenerate commandOutput
ASP.NET Core (Swashbuckle)dotnet swagger tofile --output spec.json YourApi.dll v1spec.json
FastAPIpython -c "import json; from api.main import app; json.dump(app.openapi(), open('spec.json','w'))"spec.json
NestJS (@nestjs/swagger)ts-node scripts/generate-openapi.tsspec.json (from writeFileSync in script)

Common Configuration Mistakes

MistakeSymptomFix
Missing JsonStringEnumConverter in .NETTypeScript enum values are numbers, not stringsAdd options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())
Not Gitignoring .env in backendClerk secrets committed.env in .gitignore; use GitHub Secrets in CI
Date field typed as string in TypeScripttoLocaleDateString() throws TypeErrorAdd Zod .datetime().transform(s => new Date(s))
Generated types committed but not regenerated after spec changeTypeScript sees old types; new API behavior invisibleRun pnpm run generate:types after pulling changes
Nullable C# fields vs. optional TypeScript fieldsnull vs. undefined confusion in optional chainingNormalize in Zod schema or choose consistent convention
Auth token not forwarded from Server Component401 from .NET/Python APIUse serverFetch wrapper that calls auth().getToken()

The OpenAPI CI Pipeline (Summary)

flowchart TD
    A["Backend change pushed"]
    B["Backend CI:\nbuild → test → generate spec → commit spec to repo"]
    C["Frontend CI (triggered by spec change):\ndownload spec → pnpm orval → pnpm tsc → pnpm build"]
    D{"Breaking change?"}
    E["TypeScript error in tsc step → CI fails → developer fixes"]
    F["Build succeeds → deploy"]

    A --> B --> C --> D
    D -->|Yes| E
    D -->|No| F

Further Reading


Cross-reference: This article covers the type contract pipeline. For the architectural decision of when to use each backend, see Article 4B.3 (the polyglot decision framework). For the full .NET-as-API pattern with OpenAPI client generation, see Article 4B.1. For Python AI services with streaming responses, see Article 4B.2.