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

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-typescript for types, orval for 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 useQuery hooks for GET endpoints
  • TanStack Query useMutation hooks 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

ConceptNSwag (.NET)openapi-typescriptorvaltRPC
InputOpenAPI/Swagger specOpenAPI specOpenAPI specTypeScript router definition
OutputC# client class + modelsTypeScript .d.ts typesTS types + Query hooksNone — types shared directly
HTTP client generatedYes — full HttpClient wrapperNo — use openapi-fetchYes — configurable (axios, fetch)N/A — uses its own transport
React hooks generatedNoNoYes — TanStack QueryYes — built-in .useQuery()
Runtime validationNo (types only)NoOptional Zod schemasYes — Zod schemas on every procedure
Works with external APIsYesYesYesNo — both sides must be TypeScript
CI codegen requiredYesYesYesNo
Generated files in gitYes (large diffs)No (schema file only)YesNo
Spec drift riskYes — if codegen not runYesYesN/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 ConceptTypeScript EquivalentTool
nswag openapi2csclientopenapi-typescript ./spec.yaml -o schema.d.tsopenapi-typescript
Generated OrdersApiClient classcreateClient<paths>(config)openapi-fetch
Generated DTO classesGenerated paths and components interface typesopenapi-typescript
client.GetOrderAsync(id)api.GET("/orders/{id}", { params: { path: { id } } })openapi-fetch
client.PlaceOrderAsync(request)api.POST("/orders", { body: request })openapi-fetch
No equivalentuseGetOrder(id) — generated TanStack Query hookorval
No equivalentusePlaceOrder() — generated mutation hookorval
Shared types via assembly referencetRPC router type inference — no codegentRPC
nswag.json config fileorval.config.tsorval
Run codegen in CInpx openapi-typescript or npx orval in workflowCI script
Check for spec driftgit diff --exit-code generated-file.ts after codegenCI 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:

ScenarioRecommended Tool
Consuming an external API (Stripe, GitHub, etc.)openapi-typescript + openapi-fetch
Your own API, React frontend, TanStack Queryorval
Your own API, TS-only stack, no external consumerstRPC
Your own API with mobile or non-TS consumersopenapi-typescript + openapi-fetch or orval
Cross-language API contracts (Article 4B.4)openapi-typescript from shared spec

Further Reading