4.10 — API Client Generation: NSwag vs. OpenAPI Codegen
For .NET engineers who know: NSwag, Swashbuckle, generating typed C# clients from Swagger/OpenAPI specs You’ll learn: The TypeScript equivalents of NSwag —
openapi-typescriptfor types,orvalfor TanStack Query hooks, and tRPC as the option that eliminates codegen entirely — and when each approach is the right tool Time: 10-15 minutes
The .NET Way (What You Already Know)
NSwag is the standard tool for generating typed API clients in .NET. Given a running ASP.NET Core API or a swagger.json spec file, it generates complete C# client classes: typed request/response models, method signatures matching your controller routes, and HttpClient-based implementations.
# .NET — generate C# client from a running API
dotnet tool install -g NSwag.Tool
nswag openapi2csclient \
/input:http://localhost:5000/swagger/v1/swagger.json \
/output:OrdersApiClient.cs \
/namespace:MyApp.ApiClients \
/classname:OrdersApiClient
The output is a complete, typed client:
// C# — NSwag-generated client (excerpt)
public partial class OrdersApiClient
{
private readonly HttpClient _httpClient;
public OrdersApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <exception cref="ApiException">Thrown when the request fails.</exception>
public async Task<OrderDto> GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/orders/{id}");
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return JsonSerializer.Deserialize<OrderDto>(
await response.Content.ReadAsStringAsync(),
_jsonOptions);
}
public async Task<IReadOnlyList<OrderDto>> ListOrdersAsync(
Guid? customerId = null,
OrderStatus? status = null,
CancellationToken cancellationToken = default)
{ /* ... */ }
}
You register the generated client in DI, inject it into your services, and you have compile-time safety that spans the client-server boundary. If the API changes the response shape or removes an endpoint, the generated code updates on the next codegen run and the type errors are compiler errors.
The TypeScript ecosystem achieves the same outcome, but with more tool choices and a different set of trade-offs.
The TypeScript Way
The Three Approaches
There is no single “TypeScript NSwag.” The ecosystem has three distinct tools that occupy different points on the trade-off spectrum:
openapi-typescript orval tRPC
────────────────── ───────────────── ─────────────────────
Types only (no Types + TanStack No OpenAPI at all —
HTTP calls) Query hooks + mutations share types directly
via TypeScript modules
External APIs Your own API Your own API,
Remote specs consumed in React TS-only stack
Lowest codegen cost Most complete Highest setup cost,
for React projects no external API support
openapi-typescript: Types Without the Client
openapi-typescript generates TypeScript types from an OpenAPI spec — it does not generate an HTTP client. The output is a single .d.ts file containing types for every path, method, request body, and response body in your spec.
pnpm add -D openapi-typescript
# Generate types from a local spec
npx openapi-typescript ./openapi.yaml -o src/api/schema.d.ts
# Generate from a running server
npx openapi-typescript http://localhost:3000/api-json -o src/api/schema.d.ts
The output looks like this (simplified):
// TypeScript — auto-generated schema.d.ts (excerpt)
export interface paths {
"/orders/{id}": {
get: {
parameters: { path: { id: string } };
responses: {
200: { content: { "application/json": components["schemas"]["OrderDto"] } };
404: { content: { "application/json": components["schemas"]["ProblemDetails"] } };
};
};
};
"/orders": {
post: {
requestBody: {
content: { "application/json": components["schemas"]["PlaceOrderRequest"] };
};
responses: {
201: { content: { "application/json": components["schemas"]["OrderDto"] } };
};
};
};
}
export interface components {
schemas: {
OrderDto: {
id: string;
customerId: string;
status: "pending" | "confirmed" | "shipped" | "cancelled";
total: number;
items: components["schemas"]["OrderItemDto"][];
};
// ...
};
}
You then use these types with the openapi-fetch companion library, which is a typed wrapper around the Fetch API:
pnpm add openapi-fetch
// TypeScript — typed API client using openapi-fetch + generated schema
import createClient from "openapi-fetch";
import type { paths } from "./api/schema";
// Create a typed client instance
const apiClient = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
headers: {
"Content-Type": "application/json",
},
});
// TypeScript knows the request and response shapes for every endpoint
const { data, error } = await apiClient.GET("/orders/{id}", {
params: { path: { id: "ord_abc123" } },
});
// data is typed as OrderDto | undefined
// error is typed as the 404 response body | undefined
const { data: newOrder, error: createError } = await apiClient.POST("/orders", {
body: {
customerId: "usr_xyz789",
items: [{ productId: "prod_123", quantity: 2 }],
},
});
The key difference from NSwag: openapi-fetch does not generate code — it is a generic typed wrapper that uses the generated schema types at compile time. There is no generated client file to check in. This avoids the common problem of generated files creating massive diffs in pull requests.
orval: Types + TanStack Query Hooks
orval goes further than openapi-typescript. It generates:
- TypeScript types (same as
openapi-typescript) - TanStack Query
useQueryhooks for GET endpoints - TanStack Query
useMutationhooks for POST/PUT/PATCH/DELETE endpoints - Zod schemas for runtime validation (optional)
- MSW (Mock Service Worker) mocks for testing (optional)
If your frontend uses TanStack Query (Article 5.3), orval eliminates the boilerplate of writing custom hooks around every API call.
pnpm add -D orval
// orval.config.ts — configuration file
import { defineConfig } from "orval";
export default defineConfig({
ordersApi: {
input: {
target: "http://localhost:3000/api-json",
// Or a local file:
// target: "./openapi.yaml",
},
output: {
target: "./src/api/orders.generated.ts",
schemas: "./src/api/model",
client: "react-query",
override: {
mutator: {
// Custom axios instance or fetch wrapper with auth headers
path: "./src/lib/api-client.ts",
name: "apiClient",
},
},
},
hooks: {
afterAllFilesWrite: "prettier --write",
},
},
});
# Generate — run this after any API change
npx orval
The generated output for a GET endpoint:
// TypeScript — orval-generated hook (what you would write manually without it)
export const useGetOrder = (
id: string,
options?: UseQueryOptions<OrderDto, ApiError>,
) => {
return useQuery<OrderDto, ApiError>({
queryKey: getGetOrderQueryKey(id),
queryFn: () => apiClient<OrderDto>({ url: `/orders/${id}`, method: "GET" }),
...options,
});
};
export const getGetOrderQueryKey = (id: string) => [`/orders/${id}`] as const;
// Usage in a React component — no boilerplate to write
function OrderDetail({ id }: { id: string }) {
const { data: order, isPending, error } = useGetOrder(id);
if (isPending) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <OrderCard order={order} />;
}
For mutations:
// TypeScript — orval-generated mutation hook
export const usePlaceOrder = (
options?: UseMutationOptions<OrderDto, ApiError, PlaceOrderRequest>,
) => {
return useMutation<OrderDto, ApiError, PlaceOrderRequest>({
mutationFn: (body) => apiClient<OrderDto>({ url: "/orders", method: "POST", data: body }),
...options,
});
};
// Usage
function PlaceOrderForm() {
const placeOrder = usePlaceOrder({
onSuccess: (order) => router.push(`/orders/${order.id}`),
onError: (err) => toast.error(err.message),
});
return (
<form onSubmit={(e) => {
e.preventDefault();
placeOrder.mutate({ customerId, items });
}}>
{/* ... */}
</form>
);
}
tRPC: Eliminating Codegen Entirely
tRPC is a different approach. Rather than generating types from an OpenAPI spec, it shares TypeScript types directly between the server and client as a TypeScript module. There is no code generation, no spec file, no HTTP client. The client calls the server as if it were calling a local TypeScript function.
// TypeScript — tRPC server definition
// src/server/routers/orders.ts
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
export const ordersRouter = router({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const order = await ctx.db.order.findUniqueOrThrow({
where: { id: input.id },
});
return order;
}),
place: protectedProcedure
.input(z.object({
customerId: z.string(),
items: z.array(z.object({ productId: z.string(), quantity: z.number().positive() })),
}))
.mutation(async ({ input, ctx }) => {
return ctx.orderService.placeOrder(input);
}),
});
// TypeScript — tRPC client (React) — typed automatically, no codegen
import { trpc } from "~/lib/trpc";
function OrderDetail({ id }: { id: string }) {
// This is fully typed — including the input and output shapes
// The types come from the server router definition directly
const { data: order, isPending } = trpc.orders.getById.useQuery({ id });
// ...
}
function PlaceOrderForm() {
const placeOrder = trpc.orders.place.useMutation({
onSuccess: (order) => router.push(`/orders/${order.id}`),
});
// ...
}
tRPC’s advantage: zero codegen, zero spec drift, zero intermediate files. The TypeScript compiler catches type errors at the call site immediately when the server procedure changes. This is the closest thing to .NET’s in-process service-to-service calls where you share types by reference.
The limitation — and it is significant — is that tRPC only works when both the client and server are TypeScript. You cannot call a tRPC endpoint from a .NET service, a Python script, or a mobile app. For any cross-language communication, you need OpenAPI.
Key Differences
| Concept | NSwag (.NET) | openapi-typescript | orval | tRPC |
|---|---|---|---|---|
| Input | OpenAPI/Swagger spec | OpenAPI spec | OpenAPI spec | TypeScript router definition |
| Output | C# client class + models | TypeScript .d.ts types | TS types + Query hooks | None — types shared directly |
| HTTP client generated | Yes — full HttpClient wrapper | No — use openapi-fetch | Yes — configurable (axios, fetch) | N/A — uses its own transport |
| React hooks generated | No | No | Yes — TanStack Query | Yes — built-in .useQuery() |
| Runtime validation | No (types only) | No | Optional Zod schemas | Yes — Zod schemas on every procedure |
| Works with external APIs | Yes | Yes | Yes | No — both sides must be TypeScript |
| CI codegen required | Yes | Yes | Yes | No |
| Generated files in git | Yes (large diffs) | No (schema file only) | Yes | No |
| Spec drift risk | Yes — if codegen not run | Yes | Yes | N/A |
Gotchas for .NET Engineers
Gotcha 1: Generated Files Should Not Be Committed — Or Should Always Be Committed
There is a split in the community on whether generated files belong in version control. The NSwag convention in .NET is to check them in and treat them as source files — they show up in diffs, which makes API changes visible during code review.
The TypeScript community leans the other way: run codegen as a CI step, do not commit the output. This keeps diffs clean but means your CI pipeline must regenerate types on every build. If the API server is not running during CI, you need to commit the spec file (openapi.yaml or swagger.json) to the repository and generate from that.
Pick one approach and document it. The worst outcome is some developers running codegen locally and committing stale generated files while CI regenerates fresh ones — your generated files will drift from each other and produce confusing merge conflicts.
The recommended approach for openapi-typescript: commit the openapi.yaml spec file. Run codegen from the spec in CI and during local development. Do not commit schema.d.ts.
For orval: the generated hooks are more substantial and tend to get committed. Configure afterAllFilesWrite: "prettier --write" so the format is consistent.
Gotcha 2: tRPC Is Not a REST API — You Cannot Consume It from Other Services
A tRPC endpoint is not HTTP REST. It uses HTTP as transport but the wire format and routing conventions are tRPC-specific. You cannot call a tRPC endpoint with curl, from a .NET service, or from a mobile app using a REST client. The endpoint URLs look like /api/trpc/orders.getById — not /api/orders/{id}.
If you start a project with tRPC and later need to expose an endpoint to a third party, a mobile app, or a partner service, you will need to either add a separate REST layer alongside tRPC or migrate to an OpenAPI-based approach.
The safe heuristic: use tRPC for the internal surface between your Next.js frontend and your NestJS backend when both are TypeScript and you control both ends. Use OpenAPI for anything that crosses a language or team boundary. Article 4B.4 covers the cross-language OpenAPI patterns in more depth.
Gotcha 3: orval Mutations Do Not Automatically Invalidate Queries — You Have To Configure This
NSwag-generated clients are just HTTP wrappers — they know nothing about caching. orval generates TanStack Query hooks, and TanStack Query does cache client-side state. When you call usePlaceOrder().mutate(), TanStack Query does not automatically invalidate the useListOrders query. You have to configure this explicitly.
This is not an orval bug — it is a TanStack Query design decision. But .NET engineers who expect “I sent a POST, the GET should now return updated data” are surprised when stale data remains in the cache.
// TypeScript — configure query invalidation after mutation
// This is manual in TanStack Query, not automatic
const queryClient = useQueryClient();
const placeOrder = usePlaceOrder({
onSuccess: () => {
// Invalidate the list query so it refetches with the new order
queryClient.invalidateQueries({ queryKey: ["/orders"] });
},
});
orval can generate this boilerplate if you configure it with mutator options that include the queryClient, but you must understand what it is doing and why. See Article 5.3 for TanStack Query cache invalidation patterns.
Gotcha 4: OpenAPI Spec Drift Is Your Biggest Long-Term Risk
NSwag has the same problem — if you forget to regenerate after an API change, your client types are wrong and the compiler cannot catch it. In .NET this is somewhat mitigated by keeping the API and its consumers in the same solution, so the spec always reflects the code.
In a separate frontend/backend repository setup (common in this stack), spec drift is a real production risk. Automate it:
# .github/workflows/codegen.yml — run on every push to main
name: Regenerate API Types
on:
push:
branches: [main]
pull_request:
paths:
- "apps/api/src/**"
- "openapi.yaml"
jobs:
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with: { node-version: 22 }
- run: pnpm install
- name: Regenerate types
run: npx openapi-typescript ./openapi.yaml -o apps/web/src/api/schema.d.ts
- name: Check for drift
run: git diff --exit-code apps/web/src/api/schema.d.ts
# Fails if generated types changed without a codegen run — CI catches drift
Alternatively: generate the spec from the running API in CI, run codegen, and commit the result. If the spec changed but the generated file was not committed, the diff check fails and the PR cannot merge.
Hands-On Exercise
This exercise sets up automated OpenAPI type generation for a NestJS + Next.js project and compares the experience with a tRPC approach.
Prerequisites: A NestJS API with Swagger configured (@nestjs/swagger) and a Next.js frontend in the same monorepo.
Part A — openapi-typescript setup
Step 1 — Export the spec from NestJS
NestJS with @nestjs/swagger exposes the spec at /api-json (JSON) or /api-yaml (YAML). Add a script to your package.json that fetches it and saves it to the repo:
{
"scripts": {
"spec:export": "curl http://localhost:3000/api-json -o openapi.json"
}
}
Step 2 — Generate types
npx openapi-typescript ./openapi.json -o apps/web/src/api/schema.d.ts
Step 3 — Create the typed client
// apps/web/src/lib/api.ts
import createClient from "openapi-fetch";
import type { paths } from "../api/schema";
export const api = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000",
});
Step 4 — Use the typed client in a page
Replace any untyped fetch() calls in one page with api.GET() or api.POST() calls. Verify that TypeScript catches a wrong parameter type or an incorrect response property access.
Step 5 — Add a CI check
Add a GitHub Actions workflow step that regenerates the types from the committed openapi.json and fails if the output differs from what is committed.
Part B — tRPC comparison (stretch goal)
If your project is a TypeScript-to-TypeScript stack with no external API consumers planned:
Step 6 — Install tRPC
pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query
Step 7 — Define one procedure
Migrate one endpoint (for example, GET /orders/:id) from an OpenAPI route to a tRPC query procedure. Keep the original REST endpoint alongside it.
Step 8 — Compare the developer experience
Make a change to the return type of the tRPC procedure (add a field). Observe how TypeScript propagates the error to the call site immediately — no codegen required. Then make the equivalent change to the OpenAPI spec and observe that you must run codegen to see the error in the frontend.
Document your findings: which felt more productive for this workflow? What would make you choose one over the other?
Quick Reference
| .NET / NSwag Concept | TypeScript Equivalent | Tool |
|---|---|---|
nswag openapi2csclient | openapi-typescript ./spec.yaml -o schema.d.ts | openapi-typescript |
Generated OrdersApiClient class | createClient<paths>(config) | openapi-fetch |
| Generated DTO classes | Generated paths and components interface types | openapi-typescript |
client.GetOrderAsync(id) | api.GET("/orders/{id}", { params: { path: { id } } }) | openapi-fetch |
client.PlaceOrderAsync(request) | api.POST("/orders", { body: request }) | openapi-fetch |
| No equivalent | useGetOrder(id) — generated TanStack Query hook | orval |
| No equivalent | usePlaceOrder() — generated mutation hook | orval |
| Shared types via assembly reference | tRPC router type inference — no codegen | tRPC |
nswag.json config file | orval.config.ts | orval |
| Run codegen in CI | npx openapi-typescript or npx orval in workflow | CI script |
| Check for spec drift | git diff --exit-code generated-file.ts after codegen | CI script |
| Swashbuckle for spec generation | @nestjs/swagger + SwaggerModule.setup() | NestJS |
/swagger/v1/swagger.json | /api-json (NestJS default) | NestJS Swagger |
When to use which tool:
| Scenario | Recommended Tool |
|---|---|
| Consuming an external API (Stripe, GitHub, etc.) | openapi-typescript + openapi-fetch |
| Your own API, React frontend, TanStack Query | orval |
| Your own API, TS-only stack, no external consumers | tRPC |
| Your own API with mobile or non-TS consumers | openapi-typescript + openapi-fetch or orval |
| Cross-language API contracts (Article 4B.4) | openapi-typescript from shared spec |
Further Reading
- openapi-typescript — Getting Started — Official docs covering spec ingestion,
openapi-fetchusage, and type narrowing patterns - orval — Documentation — Configuration reference for all client generators (react-query, axios, fetch, swr), Zod output, and MSW mock generation
- tRPC — Quickstart — The minimal setup guide; the “Why tRPC?” section is worth reading for the trade-off framing
- NestJS — OpenAPI (Swagger) — How to configure
@nestjs/swaggerto generate the spec that all three tools consume