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
nullwhere 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 system | Polyglot with OpenAPI bridge |
|---|---|---|
| Type sharing | Compile-time (shared C# assemblies) | Contract-based (OpenAPI spec → generated types) |
| Breaking change detection | Compile error immediately | CI pipeline failure (one build cycle lag) |
| Runtime validation | .NET type system validates deserialization | Zod required for runtime safety |
| Auth propagation | Windows Auth, ASP.NET Identity cookies — automatic | JWT must be explicitly forwarded per request |
| Enum serialization | Integer by default (configure JsonStringEnumConverter) | String required for TypeScript union type generation |
| Date handling | DateTime serializes as ISO 8601 | TypeScript Date requires explicit Zod transform |
| Null vs. undefined | C# null → JSON null | TypeScript distinguishes null from undefined; Zod handles the mapping |
| Code generation cadence | One-time setup with NSwag | Per-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
| Need | Tool | Notes |
|---|---|---|
| Types only from OpenAPI spec | openapi-typescript | Lightest; bring your own HTTP client |
| Types + TanStack Query hooks | orval | Recommended; generates everything |
| Typed fetch client (lighter than orval) | openapi-fetch | Pairs with openapi-typescript |
| Breaking change detection | oasdiff | Use in CI on PR branches |
| Runtime response validation | zod | Complements generated types; not a replacement |
Backend OpenAPI Generation Commands
| Backend | Generate command | Output |
|---|---|---|
| ASP.NET Core (Swashbuckle) | dotnet swagger tofile --output spec.json YourApi.dll v1 | spec.json |
| FastAPI | python -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.ts | spec.json (from writeFileSync in script) |
Common Configuration Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Missing JsonStringEnumConverter in .NET | TypeScript enum values are numbers, not strings | Add options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()) |
Not Gitignoring .env in backend | Clerk secrets committed | .env in .gitignore; use GitHub Secrets in CI |
Date field typed as string in TypeScript | toLocaleDateString() throws TypeError | Add Zod .datetime().transform(s => new Date(s)) |
| Generated types committed but not regenerated after spec change | TypeScript sees old types; new API behavior invisible | Run pnpm run generate:types after pulling changes |
| Nullable C# fields vs. optional TypeScript fields | null vs. undefined confusion in optional chaining | Normalize in Zod schema or choose consistent convention |
| Auth token not forwarded from Server Component | 401 from .NET/Python API | Use 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
- openapi-typescript documentation — The authoritative reference for type generation from OpenAPI specs
- orval documentation — Configuration reference for generating TanStack Query hooks, including custom mutator setup
- oasdiff — Breaking change detection tool used in the GitHub Actions workflows above
- FastAPI — Advanced: Generate Client — FastAPI’s official guide for client generation, including tips for TypeScript consumers
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.