Skip to content

Working with TypeScript Client

davidkallesen edited this page Jun 2, 2026 · 6 revisions

🟦 Working with TypeScript Client

The CLI can generate a fully typed TypeScript client from your OpenAPI specification. The generated code supports both the native fetch API (zero dependencies) and Axios, making it compatible with any framework β€” React, Vue, Angular, Svelte, or plain TypeScript.

⚑ Quick Start

# Install the CLI tool
dotnet tool install -g atc-rest-api-gen

# Generate TypeScript client from your OpenAPI spec
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api

Command Options

Option Description Default
-s, --specification Path to the OpenAPI specification file (YAML or JSON) Required
-o, --output Output directory for generated TypeScript files Required
--options Path to ApiGeneratorOptions.json (auto-discovered if omitted)
--enum-style Enum generation style: Union or Enum Union
--enum-runtime-values For Union enums, also emit a runtime {EnumName}Values const array (as const)
--client-type HTTP client library: Fetch or Axios Fetch
--hooks Hook generation: None, ReactQuery, or Swr None
--hooks-mode React Query variant: Standard, Suspense, or Both Standard
--msw Generate Mock Service Worker handlers for testing
--naming-strategy Property casing: CamelCase, Original, or PascalCase CamelCase
--convert-dates Map date/date-time to native Date type
--no-readonly Omit readonly modifier from model properties
--zod Generate Zod validation schemas alongside models
--zod-runtime-validate Validate responses against Zod at runtime; adds schemaMismatch arm. Implies --zod
--branded-ids Emit nominal types for string + format: uuid properties ending in Id
--scaffold Generate package.json and tsconfig.json (dual ESM/CJS)
--package-name Override package name (default: derived from OpenAPI title)
--package-version Override package version 0.1.0
--no-strict Disable strict validation (skip naming convention checks)
--include-deprecated Include deprecated operations and schemas
--report Generate a .generation-report.md file in the output directory
--dry-run Preview what would be generated without writing any files
--watch Watch the spec file for changes and re-generate automatically

Examples

# Basic β€” fetch client with union enums
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api

# Axios client with React Query hooks
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --client-type Axios --hooks ReactQuery

# Full-featured β€” Zod, dates, scaffold, hooks
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --hooks ReactQuery --zod --convert-dates --scaffold

# Ready-to-publish npm package
atc-rest-api-gen generate client-typescript -s api.yaml -o ./my-api-client \
  --scaffold --package-name @myorg/api-client --package-version 1.0.0

# Suspense-boundary friendly β€” useSuspenseQuery only
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --hooks ReactQuery --hooks-mode Suspense

# Branded IDs + runtime Zod validation β€” catch caller mistakes and spec drift
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --branded-ids --zod-runtime-validate --hooks ReactQuery

# MSW handlers for tests and storybook
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --msw --hooks ReactQuery

# Dry run β€” preview generation counts without writing files
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --dry-run

# Watch mode β€” re-generate automatically on spec changes (Ctrl+C to stop)
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --watch

πŸ—ΊοΈ Type Mapping

The generator maps OpenAPI types to TypeScript as follows:

OpenAPI Type OpenAPI Format TypeScript Type
string β€” string
string date string (or Date with --convert-dates)
string date-time string (or Date with --convert-dates)
string uuid string
string email string
string uri string
string binary Blob | File
string enum Union type or enum
integer int32 / int64 number
number float / double number
boolean β€” boolean
array β€” T[]
object β€” interface or Record<string, unknown>
nullable β€” T | null

πŸ“‚ Generated Output Structure

Running the generator produces a self-contained api/ directory:

src/api/
β”œβ”€β”€ client/
β”‚   β”œβ”€β”€ ApiClient.ts          # Base HTTP client (fetch or Axios wrapper)
β”‚   β”œβ”€β”€ ApiService.ts         # Root orchestrator β€” aggregates all segment clients
β”‚   β”œβ”€β”€ AccountsClient.ts     # Typed client for /accounts endpoints
β”‚   β”œβ”€β”€ UsersClient.ts        # Typed client for /users endpoints
β”‚   β”œβ”€β”€ FilesClient.ts        # Typed client for /files endpoints
β”‚   └── index.ts              # Barrel export
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ Account.ts            # Interface per OpenAPI schema
β”‚   β”œβ”€β”€ Account.zod.ts        # Zod schema (with --zod)
β”‚   β”œβ”€β”€ User.ts
β”‚   β”œβ”€β”€ UserWritable.ts       # Request-side variant (with readOnly/writeOnly split)
β”‚   β”œβ”€β”€ User.zod.ts
β”‚   └── index.ts
β”œβ”€β”€ enums/
β”‚   β”œβ”€β”€ UserRole.ts           # Union type or TS enum (configurable)
β”‚   β”œβ”€β”€ UserRole.zod.ts       # Zod enum schema (with --zod)
β”‚   └── index.ts
β”œβ”€β”€ errors/
β”‚   β”œβ”€β”€ ApiError.ts           # Base error class
β”‚   β”œβ”€β”€ ValidationError.ts    # 400 response with field errors
β”‚   └── index.ts
β”œβ”€β”€ types/
β”‚   β”œβ”€β”€ ApiResult.ts          # Discriminated union for all responses
β”‚   β”œβ”€β”€ BrandedIds.ts         # (with --branded-ids) Nominal ID types
β”‚   └── index.ts
β”œβ”€β”€ helpers/
β”‚   β”œβ”€β”€ retryConfig.ts        # (when x-retry-* present) Per-op retry policies
β”‚   └── retryInterceptor.ts
β”œβ”€β”€ hooks/                    # (with --hooks ReactQuery or Swr)
β”‚   β”œβ”€β”€ ApiProvider.ts        # React context provider
β”‚   β”œβ”€β”€ useApiService.ts      # Hook to access ApiService from context
β”‚   β”œβ”€β”€ useAccounts.ts        # Per-segment hooks with query key factories
β”‚   β”œβ”€β”€ useUsers.ts
β”‚   └── index.ts
β”œβ”€β”€ hubs/                     # (when x-signalr-hubs vendor extension present)
β”‚   β”œβ”€β”€ useNotificationsHub.ts # Typed SignalR hub hook per declared hub
β”‚   └── index.ts
β”œβ”€β”€ servers/                  # (when 2+ servers declared)
β”‚   β”œβ”€β”€ Servers.ts            # Typed const + ServerName keyof union
β”‚   └── index.ts
β”œβ”€β”€ webhooks/                 # (when operation callbacks: present)
β”‚   β”œβ”€β”€ Webhooks.ts           # Typed callback payload aliases
β”‚   └── index.ts
β”œβ”€β”€ mocks/                    # (with --msw)
β”‚   β”œβ”€β”€ accounts.ts           # Per-segment MSW handlers
β”‚   β”œβ”€β”€ users.ts
β”‚   β”œβ”€β”€ handlers.ts           # Combined handler array β€” `import { handlers }`
β”‚   └── index.ts              # Barrel β€” `import * from './mocks'` resolves here
β”œβ”€β”€ README.md                 # (with --scaffold) Auto-generated quick-start
β”œβ”€β”€ package.json              # (with --scaffold) Dual ESM/CJS exports map
β”œβ”€β”€ tsconfig.json             # (with --scaffold)
β”œβ”€β”€ tsconfig.cjs.json         # (with --scaffold) CJS build profile
└── index.ts                  # Root barrel export

Clients are organized by the first path segment of each endpoint β€” the same grouping used for C# server code.

πŸ”Œ Using the Client

ApiService β€” Root Orchestrator

The generated ApiService class aggregates all segment clients into a single entry point:

import { ApiService } from './api/client/ApiService';

const api = new ApiService('https://localhost:7100/api/v1', {
  getAccessToken: () => localStorage.getItem('token') ?? '',
  defaultHeaders: { 'X-Request-Source': 'web' },
});

// Access any segment through the service
const accounts = await api.accounts.listAccounts();
const user = await api.users.getUserById('abc-123');
await api.files.uploadSingleFile(file);

Individual Clients

You can also instantiate segment clients directly:

import { ApiClient } from './api/client/ApiClient';
import { AccountsClient } from './api/client/AccountsClient';

const apiClient = new ApiClient('https://localhost:7100/api/v1', {
  getAccessToken: () => localStorage.getItem('token') ?? '',
});

const accountsClient = new AccountsClient(apiClient);
const result = await accountsClient.listAccounts({ limit: 10 });

Calling Endpoints

const result = await api.accounts.listAccounts({ limit: 10 });

if (result.status === 'ok') {
  console.log(result.data); // Account[] β€” fully typed
}

Every method returns Promise<ApiResult<T>> β€” a discriminated union that makes response handling type-safe.

🎯 ApiResult Pattern

Each client method returns a per-operation discriminated union whose arms match the response codes the OpenAPI spec actually declares. The generic ApiResult<T> exists too (used by request() internally) and carries every standard arm; the per-op alias narrows it down to only what's reachable.

A typical per-op result type:

// client/PetsClient.ts (auto-generated)
export type GetPetResult =
  | { status: 'ok'; data: Pet; response: Response }
  | { status: 'notFound'; error: ApiError; response: Response }
  | { status: 'parseError'; error: Error; response: Response };

The full set of arms used by the generator:

Arm When emitted Payload
ok 200 declared data: TModel
created 201 declared data: TModel
accepted 202 declared data: TModel
noContent 204 declared (none)
badRequest 400 declared error: ValidationError
unauthorized / forbidden / notFound / conflict / unprocessableEntity / tooManyRequests / serverError / gatewayTimeout 401/403/404/409/422/429/5xx declared error: ApiError
parseError Always (synthetic) β€” body parsed but JSON malformed error: Error
schemaMismatch --zod-runtime-validate + response failed Zod parse issues: ZodIssue[], data: unknown

default: responses in the spec fan out to the common 4xx/5xx arms so a generic PetStore-style default: { $ref: '#/components/schemas/Error' } still narrows correctly without listing every status code.

Type Guards

Generated type guard functions provide type narrowing:

import { isOk, isBadRequest, isNotFound } from './api/types/ApiResult';

const result = await api.users.getUserById(userId);

if (isOk(result)) {
  // result.data is typed as User
  console.log(result.data.firstName);
} else if (isNotFound(result)) {
  // result.error is typed as ApiError
  console.log(result.error.message);
} else if (isBadRequest(result)) {
  // result.error is typed as ValidationError
  console.log(result.error.errors); // Record<string, string[]>
}

Validation Errors

400 responses with field-level errors are parsed into ValidationError:

if (isBadRequest(result)) {
  // errors is Record<string, string[]>
  for (const [field, messages] of Object.entries(result.error.errors)) {
    console.log(`${field}: ${messages.join(', ')}`);
  }
  // Output: "Request.FirstName: FirstName must start with an uppercase letter."
}

πŸ”€ HTTP Client Libraries

Fetch (default)

The default --client-type Fetch uses the native fetch API with zero runtime dependencies:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api

Axios

Use --client-type Axios to generate an Axios-based client. The Axios variant uses axios.create() with validateStatus: () => true (prevents Axios from throwing on error codes), auth via interceptor, and AxiosResponse throughout:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --client-type Axios

Only 4 infrastructure files differ between modes (ApiClient.ts, ApiError.ts, ValidationError.ts, ApiResult.ts). Segment clients, hooks, models, enums, and ApiService are identical across both variants.

πŸ’‘ Streaming endpoints (requestStream) always use native fetch in the Axios variant, since Axios lacks ReadableStream iteration support.

πŸ”’ Interceptors

Both Fetch and Axios clients support request and response interceptors via ApiClientOptions:

Fetch Interceptors

import type { FetchRequestInterceptor, FetchResponseInterceptor } from './api/client/ApiClient';

const logRequest: FetchRequestInterceptor = (url, init) => {
  console.log(`${init.method} ${url}`);
  return init;
};

const logResponse: FetchResponseInterceptor = (response) => {
  console.log(`${response.status} ${response.url}`);
  return response;
};

const api = new ApiService('https://localhost:7100/api/v1', {
  requestInterceptors: [logRequest],
  responseInterceptors: [logResponse],
});

Axios Interceptors

import type { AxiosRequestInterceptor, AxiosResponseInterceptor } from './api/client/ApiClient';

const addCorrelationId: AxiosRequestInterceptor = (config) => {
  config.headers['X-Correlation-Id'] = crypto.randomUUID();
  return config;
};

const api = new ApiService('https://localhost:7100/api/v1', {
  requestInterceptors: [addCorrelationId],
});

πŸ’‘ For the Axios variant, interceptors are registered on the Axios instance after the auth interceptor. The auth interceptor always runs first.

βš›οΈ React Query Hooks

The --hooks ReactQuery flag auto-generates TanStack Query hooks alongside the clients, eliminating the need to write them by hand.

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --hooks ReactQuery

Generated Hook Structure

Each API segment gets a hook file with:

  • Query key factory for cache management β€” includes path and query params so cached entries fan out per filter combination
  • useQuery hooks for GET operations (or useSuspenseQuery under --hooks-mode Suspense)
  • useMutation hooks for POST/PUT/PATCH/DELETE with automatic cache invalidation
  • useXxxStream hooks for x-return-async-enumerable operations (see Streaming hooks)
  • useXxxInfinite hooks for paginated streaming (see useInfiniteQuery)
  • Per-op options? pass-through β€” every hook accepts options?: Omit<UseQueryOptions<TData, ApiError>, 'queryKey' | 'queryFn'> (or UseMutationOptions<TData, ApiError, TVariables> for mutations) so callers can override staleTime, enabled, select, onSuccess, etc. without wrapping the hook. For mutations, the generator wraps onSuccess with queryClient.invalidateQueries({ queryKey: <segment>Keys.all }) and then forwards to the caller's onSuccess β€” the cache invalidation always runs alongside whatever the caller passes.

Setup β€” Wrap Your App

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ApiProvider } from './api/hooks/ApiProvider';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ApiProvider
        baseUrl="https://localhost:7100/api/v1"
        options={{ getAccessToken: () => token }}
      >
        <YourApp />
      </ApiProvider>
    </QueryClientProvider>
  );
}

Using Generated Hooks

import { useListAccounts, useCreateAccount } from './api/hooks/useAccounts';

function AccountsPage() {
  const { data: accounts, isLoading } = useListAccounts();
  const createAccount = useCreateAccount();

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      {accounts?.map((a) => <div key={a.id}>{a.name}</div>)}
      <button onClick={() => createAccount.mutate({ id: 0, name: 'New' })}>
        Add Account
      </button>
    </div>
  );
}

Query Key Factories

Each hook file exports a key factory for manual cache management:

import { accountsKeys } from './api/hooks/useAccounts';

// Invalidate all accounts queries
queryClient.invalidateQueries({ queryKey: accountsKeys.all });

// Invalidate a specific account
queryClient.invalidateQueries({ queryKey: accountsKeys.detail('abc-123') });

πŸ”„ SWR Hooks

The --hooks Swr flag generates SWR hooks as an alternative to React Query.

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --hooks Swr

Generated Hook Structure

Each API segment gets a hook file with:

  • useSWR hooks for GET operations
  • useSWRMutation hooks for POST/PUT/PATCH/DELETE
  • useXxxStream hooks for x-return-async-enumerable operations β€” same useState/useEffect/useRef/AbortController shape as the React Query variant (the streaming hook is framework-agnostic React code)

Setup β€” Wrap Your App

import { SWRConfig } from 'swr';
import { ApiProvider } from './api/hooks/ApiProvider';

function App() {
  return (
    <SWRConfig value={{ revalidateOnFocus: false }}>
      <ApiProvider baseUrl="https://localhost:7100/api/v1">
        <YourApp />
      </ApiProvider>
    </SWRConfig>
  );
}

Using Generated Hooks

import { useListAccounts, useCreateAccount } from './api/hooks/useAccounts';

function AccountsPage() {
  const { data: accounts, isLoading } = useListAccounts();
  const { trigger: createAccount } = useCreateAccount();

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      {accounts?.map((a) => <div key={a.id}>{a.name}</div>)}
      <button onClick={() => createAccount({ id: 0, name: 'New' })}>
        Add Account
      </button>
    </div>
  );
}

πŸ’‘ SWR uses swr as a peer dependency. Install it alongside React: npm install swr.

πŸ§ͺ MSW Mock Handlers

The --msw flag generates Mock Service Worker handlers from your OpenAPI spec, enabling realistic API mocking in tests and development.

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --msw

Generated Structure

Each path segment produces a handler file with mock responses derived from OpenAPI schemas:

mocks/
β”œβ”€β”€ accounts.ts             # GET /accounts β†’ mock array, POST β†’ mock 201, etc.
β”œβ”€β”€ users.ts
β”œβ”€β”€ handlers.ts             # Combined: export all segment handlers as one array
└── index.ts                # Barrel β€” re-exports './handlers' so api/index.ts resolves

πŸ’‘ The mocks/index.ts barrel exists specifically so the root api/index.ts can export * from './mocks' under strict tsc -b without a TS2307: Cannot find module error.

Usage in Tests

import { setupServer } from 'msw/node';
import { handlers } from './api/mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Usage in Development

import { setupWorker } from 'msw/browser';
import { handlers } from './api/mocks/handlers';

const worker = setupWorker(...handlers);
worker.start();

πŸ’‘ --msw can be combined with --hooks to get both hooks and mocks in a single generation pass.

πŸ” Retry with Exponential Backoff

The generator automatically produces retry infrastructure when it detects x-retry-* extensions in the OpenAPI spec (see Working with Resilience for extension syntax).

Generated Files

File Purpose
helpers/retryConfig.ts RetryPolicy interface and per-operation policy constants
helpers/retryInterceptor.ts retryWithBackoff() function wired into every request

RetryPolicy Configuration

interface RetryPolicy {
  readonly maxAttempts: number;
  readonly delayMs: number;
  readonly backoff: 'constant' | 'linear' | 'exponential';
  readonly useJitter: boolean;
  readonly timeoutMs?: number;
}

Behavior

  • Retries are automatic for 429 Too Many Requests (respects Retry-After header) and 5xx errors
  • Supports AbortSignal for cancellation during retry delays
  • Exponential backoff with optional jitter prevents thundering herd
  • Each operation maps to its own policy via the generated operationPolicies map

πŸ’‘ No x-retry-* extensions? No retry files are generated β€” the client stays minimal.

🚨 Error Handling Patterns

Exhaustive Status Checking

Handle all possible outcomes of an API call:

const result = await api.orders.createOrder({ items, shippingAddress });

switch (result.status) {
  case 'ok':
  case 'created':
    toast.success(`Order ${result.data.id} created!`);
    router.push(`/orders/${result.data.id}`);
    break;

  case 'badRequest':
    // Validation errors with per-field details
    setFormErrors(result.error.errors);
    break;

  case 'unauthorized':
    // Token expired β€” redirect to login
    router.push('/login');
    break;

  case 'forbidden':
    toast.error('You do not have permission to create orders.');
    break;

  case 'conflict':
    toast.error('This order already exists.');
    break;

  case 'tooManyRequests':
    toast.warning('Too many requests β€” please wait and try again.');
    break;

  case 'unprocessableEntity':
    toast.error(result.error.message);
    break;

  case 'serverError':
    toast.error('Something went wrong. Please try again later.');
    console.error(result.error);
    break;
}

With React Query Mutations

const createOrder = useCreateOrder({
  onSuccess: (result) => {
    if (result.status === 'ok' || result.status === 'created') {
      queryClient.invalidateQueries({ queryKey: ordersKeys.all });
      toast.success('Order created!');
    } else if (result.status === 'badRequest') {
      setFormErrors(result.error.errors);
    } else {
      toast.error(result.error?.message ?? 'Failed to create order');
    }
  },
});

Global Error Handling

Centralize error handling with a helper:

// api/helpers/handleApiError.ts
import type { ApiResult } from '../types/ApiResult';
import type { ApiError } from '../errors/ApiError';

export function handleApiError<T>(
  result: ApiResult<T>,
  options?: {
    onUnauthorized?: () => void;
    onForbidden?: () => void;
  }
): ApiError | null {
  switch (result.status) {
    case 'ok':
    case 'created':
    case 'noContent':
      return null; // Success β€” no error

    case 'unauthorized':
      options?.onUnauthorized?.();
      return result.error;

    case 'forbidden':
      options?.onForbidden?.();
      return result.error;

    default:
      return 'error' in result ? result.error : null;
  }
}

πŸ“ Form Integration

With React Hook Form

import { useForm } from 'react-hook-form';
import { useCreateUser } from './api/hooks/useUsers';
import { isBadRequest } from './api/types/ApiResult';
import type { CreateUserRequest } from './api/models/CreateUserRequest';

function CreateUserForm() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm<CreateUserRequest>();
  const createUser = useCreateUser();

  const onSubmit = async (data: CreateUserRequest) => {
    const result = await createUser.mutateAsync(data);

    if (isBadRequest(result)) {
      // Map API validation errors to form fields
      for (const [field, messages] of Object.entries(result.error.errors)) {
        // API returns "Request.FirstName" β€” extract the field name
        const fieldName = field.split('.').pop()!.charAt(0).toLowerCase() + field.split('.').pop()!.slice(1);
        setError(fieldName as keyof CreateUserRequest, {
          type: 'server',
          message: messages[0],
        });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} />
      {errors.firstName && <span>{errors.firstName.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

πŸ“ File Uploads

The generated client handles multipart/form-data and application/octet-stream automatically.

Single file (octet-stream)

const file = document.getElementById('fileInput') as HTMLInputElement;
const result = await api.files.uploadSingleFileAsFormData(file.files![0]);

File with metadata (multipart/form-data)

const result = await api.files.uploadSingleObjectWithFileAsFormData({
  itemName: 'photo.jpg',
  file: file.files![0],
  items: ['tag1', 'tag2'],
});

Multiple files

const result = await api.files.uploadMultiFilesAsFormData(
  Array.from(file.files!)
);

⬇️ File Downloads

Endpoints that return binary content use responseType: 'blob':

const result = await api.files.getFileById(fileId);

if (isOk(result)) {
  // result.data is typed as Blob
  const url = URL.createObjectURL(result.data);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'file.pdf';
  link.click();
  URL.revokeObjectURL(url);
}

πŸ“ Text Responses

Endpoints that declare a text/plain, text/csv, or application/xml response body with type: string are generated as methods that return the raw body as a string:

async getTextReport(): Promise<ApiResult<string>> {
  return this.api.request<string>('GET', '/reports/text', {
    responseType: 'text',
  });
}

Usage:

const result = await api.reports.getTextReport();

if (isOk(result)) {
  // result.data is typed as string β€” no JSON parsing performed
  console.log(result.data);
}

The client's responseType union is 'json' | 'blob' | 'text'. The branch is picked per-operation based on the OpenAPI response media type:

OpenAPI response media type Generated responseType result.data type
application/json (default) 'json' the model
application/octet-stream, image/*, … 'blob' Blob
text/plain, text/csv, application/xml (+ type: string) 'text' string

πŸ’‘ Tip: The underlying transport branches accordingly β€” the fetch variant calls await response.text(); the axios variant sets responseType: 'text'. Both strip the JSON code path so content types like text/csv round-trip without being mangled by a JSON parser.

🌊 Streaming

Client method: AsyncGenerator

Endpoints marked x-return-async-enumerable: true (or backed by IAsyncEnumerable<T> on the server) are generated as AsyncGenerator methods:

// Stream items one-by-one as the server yields them
for await (const account of api.accounts.listAsyncEnumerableAccounts()) {
  console.log(account); // Each item arrives as soon as the server sends it
}

You can cancel a stream by passing an AbortSignal:

const controller = new AbortController();

for await (const item of api.accounts.listAsyncEnumerableAccounts(undefined, controller.signal)) {
  items.push(item);
  if (items.length >= 50) {
    controller.abort(); // Stop streaming early
    break;
  }
}

πŸͺ Streaming hooks (useXxxStream)

With --hooks ReactQuery (or Swr), each streaming operation also gets a dedicated hook backed by useState + useEffect + useRef + AbortController:

const { items, status, error, cancel, reset } = useListItemsStream(
  { filter: 'active' },
  { enabled: shouldFetch },
);

Returned shape:

Field Type Notes
items readonly T[] Accumulates as the server yields
status 'idle' | 'streaming' | 'success' | 'error' 4-state machine, progressive-render friendly
error Error | null Surfaced if the stream throws (excluding aborts)
cancel () => void Aborts the in-flight stream
reset () => void Cancels + clears items and error

The hook re-streams whenever the query args change (keyed via JSON.stringify({ query })). Unmount aborts cleanly via the cleanup function; AbortError is swallowed so consumers don't see noise on cancel.

πŸ’‘ SWR streaming hooks have an identical shape β€” the streaming hook body is framework-agnostic React code with no React Query / SWR coupling.

♾️ useInfiniteQuery for paginated streaming

When an operation declares both x-return-async-enumerable: true and a response schema that is an allOf referencing a pagination wrapper (PaginationResult / PaginatedResult / PagedResult), the generator emits two extra artifacts alongside the streaming method:

  1. Non-streaming client companion <methodName>Page β€” same path/query params plus a synthesized headers?: { 'x-continuation'?: string } parameter. Returns Promise<<MethodName>PageResult> whose ok arm carries data: PaginationResult<Item>.
  2. useXxxInfinite hook β€” calls useInfiniteQuery, threads the previous page's continuationToken back through the x-continuation header on fetchNextPage, and reads getNextPageParam: (lastPage) => lastPage.continuationToken ?? undefined.
const {
  data,
  hasNextPage,
  fetchNextPage,
  isFetchingNextPage,
} = useListItemPagesInfinite({ pageSize: 50 });

// data.pages is PaginationResult<Item>[]
const items = data?.pages.flatMap((p) => p.items) ?? [];

The query-key factory gains a <methodName>Infinite entry so its cache lives under the segment's all prefix:

itemsKeys.listItemPagesInfinite({ pageSize: 50 })
// β†’ ['items', 'listItemPagesInfinite', { pageSize: 50 }]

πŸ”’ Enum Styles

The --enum-style flag controls how OpenAPI string enums are generated.

Union types (default: --enum-style Union)

/** User's role in the system */
export type UserRole = 'Admin' | 'Manager' | 'Employee' | 'Guest';

Union types are the default because they produce the simplest output and work well with JSON serialization β€” values are plain strings with no mapping layer.

TypeScript enums (--enum-style Enum)

/** User's role in the system */
export enum UserRole {
  Admin = 'Admin',
  Manager = 'Manager',
  Employee = 'Employee',
  Guest = 'Guest',
}

Use this style if you prefer UserRole.Admin syntax or need reverse lookups.

Runtime values for Union enums (--enum-runtime-values)

A Union type is erased at compile time, so there is no runtime list to iterate β€” populating a <select> from the enum means hand-maintaining a parallel array (which drifts). Add --enum-runtime-values to emit a tree-shakeable {EnumName}Values const array beside the type:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --enum-runtime-values
/** User's role in the system */
export type UserRole = 'Admin' | 'Manager' | 'Employee' | 'Guest';

export const UserRoleValues = ['Admin', 'Manager', 'Employee', 'Guest'] as const;

The type declaration is unchanged (so IDE hover and existing imports are unaffected); the const is purely additive and re-exported from enums/index.ts. Use it to drive UI lists:

<select>
  {UserRoleValues.map((role) => (
    <option key={role} value={role}>{role}</option>
  ))}
</select>

The flag only affects --enum-style Union. With --enum-style Enum it has no effect β€” TypeScript enums already expose Object.values(UserRole). If you use --zod, the generated UserRoleSchema.options provides the same runtime list (plus validation), so --enum-runtime-values is mainly for consumers who want runtime values without a Zod dependency.

πŸ“› Naming Strategies

The --naming-strategy flag controls property and parameter casing in generated code.

Strategy Input property Output
CamelCase (default) first_name firstName
Original first_name first_name
PascalCase first_name FirstName

Naming strategy applies to model properties, function parameters, query types, FormData access, and path interpolation. Method names (always camelCase), hook names (React convention), and query key segment names are not affected.

πŸ“… Date/Time Handling

By default, date and date-time properties are mapped to string. With --convert-dates, they are mapped to the native Date type:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --convert-dates

The generated ApiClient emits dateReviver/dateReplacer functions that automatically:

  • Convert ISO 8601 strings to Date objects on response parsing
  • Convert Date objects back to ISO 8601 strings on request serialization

This works for both Fetch and Axios client variants, including streaming items.

πŸ›‘οΈ Zod Schema Generation

The --zod flag generates Zod validation schemas alongside TypeScript types, enabling runtime validation:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --zod

Generated Schemas

Each model gets a .zod.ts file:

// models/User.zod.ts
import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string().uuid(),
  firstName: z.string().min(1).max(100),
  lastName: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['Admin', 'Manager', 'Employee', 'Guest']),
  isActive: z.boolean(),
});

Supported Constraints

OpenAPI Constraint Zod Equivalent
minLength / maxLength .min() / .max()
pattern .regex()
minimum / maximum .min() / .max()
format: email .email()
format: url .url()
format: uuid .uuid()
format: date .date()
format: date-time .datetime()
nullable .nullable()
default .default()
allOf / $ref .extend()
oneOf z.union()
additionalProperties z.record()

Usage

import { UserSchema } from './api/models/User.zod';

// Validate API responses at runtime
const result = await api.users.getUserById('abc-123');
if (isOk(result)) {
  const user = UserSchema.parse(result.data); // Throws on invalid data
}

// Validate form input before submission
const parsed = UserSchema.safeParse(formData);
if (!parsed.success) {
  console.log(parsed.error.issues);
}

πŸ“¦ NPM Package Scaffolding

The --scaffold flag generates package.json, tsconfig.json, and an auto-generated README.md, making the output a ready-to-use npm package:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./my-api-client \
  --scaffold --package-name @myorg/api-client

Dependencies are added conditionally:

  • axios (if --client-type Axios)
  • zod (if --zod or --zod-runtime-validate)
  • @tanstack/react-query + react as peer deps (if --hooks ReactQuery)
  • swr + react as peer deps (if --hooks Swr)
  • @microsoft/signalr (if the spec declares x-signalr-hubs)
  • msw as dev dependency (if --msw)
  • @types/react as dev dependency (if hooks enabled)

Auto-generated README.md

A consumer-facing README.md ships alongside the package with:

  • Quick-start (including the ApiProvider wrap when hooks are on)
  • Auth configuration snippet (getAccessToken)
  • List of generated clients + sample call sites
  • The exact regen command (echoed back from the CLI invocation)

Dual ESM / CJS output

The scaffolded package.json ships with both module systems:

  • main β†’ CJS bundle (dist/cjs/index.js)
  • module β†’ ESM bundle (dist/index.js)
  • types β†’ shared .d.ts (dist/index.d.ts)
  • exports map with import / require / types / default conditions

The scaffold writes a sibling tsconfig.cjs.json (extends the base) plus a post-build step that drops dist/cjs/package.json with "type": "commonjs" so Node resolves CJS correctly under the root's "type": "module".

// package.json β€” abridged
{
  "type": "module",
  "main": "./dist/cjs/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/index.js"
    }
  }
}

Stricter scaffolded tsconfig

The generated tsconfig.json ships with explicit "lib": ["ES2020", "DOM"], every strictness flag enabled, and a JSONC comment documenting why skipLibCheck is set the way it is. Known strict-mode failure modes β€” TS2307 (missing mocks/index.ts barrel) and TS7022 / TS2448 (self-referencing Zod schemas) β€” have dedicated unit and scenario coverage.

πŸ”’ Readonly vs. Mutable Models

By default, all model properties are generated as readonly:

export interface User {
  readonly id: string;
  readonly firstName: string;
  readonly lastName: string;
}

Use --no-readonly to omit the modifier, useful when you need to mutate model instances (e.g., form state):

export interface User {
  id: string;
  firstName: string;
  lastName: string;
}

πŸ” readOnly / writeOnly Schema Split

OpenAPI lets schemas mark individual properties as readOnly: true (server-set, omitted from requests) or writeOnly: true (request-only, never returned). The generator honors this by emitting two TypeScript interfaces per affected schema:

  • <Name> β€” response shape, drops writeOnly props
  • <Name>Writable β€” request shape, drops readOnly props (used in body parameters)
# User schema with markers
User:
  type: object
  properties:
    id: { type: string, format: uuid, readOnly: true }
    email: { type: string }
    password: { type: string, writeOnly: true }

Generated:

export interface User {
  readonly email: string;
  // No `id` for write side; no `password` for read side
}

export interface UserWritable {
  readonly email: string;
  readonly password: string;
}

Client method signatures route request bodies to <Name>Writable automatically; per-op result types keep the canonical <Name> for response data. React Query mutation hooks mirror the client behavior for body types.

πŸ’‘ The Writable suffix is intentional β€” it avoids colliding with explicit *Request schemas that real specs commonly declare side-by-side with the model.

πŸͺͺ Branded ID Types (--branded-ids)

The --branded-ids flag emits nominal types for string + format: uuid properties whose name ends in Id:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --branded-ids

Generated types/BrandedIds.ts:

export type PetId = string & { readonly __brand: 'PetId' };
export type UserId = string & { readonly __brand: 'UserId' };

Brands flow through:

  • Model interfaces β€” interface Pet { readonly id: PetId; ... }
  • Client path params β€” getPet(petId: PetId)
  • Hook signatures β€” useGetPet(petId: PetId) (React Query) and useGetPetSwr(petId) (SWR streaming)
  • Query key factories β€” keys carry the branded type

Result: getPet(userId) becomes a compile-time error, not a runtime mystery.

const petId = 'abc-123' as PetId;          // explicit cast at boundaries
const userId = 'def-456' as UserId;

api.pets.getPet(petId);   // βœ… ok
api.pets.getPet(userId);  // ❌ TS2345: Argument of type 'UserId' is not assignable to 'PetId'

πŸ’‘ Use crypto.randomUUID() as PetId (or a constructor helper) at the boundary where strings enter your application. Inside the app, the brand catches mistakes for free.

πŸ” Runtime Zod Validation (--zod-runtime-validate)

The --zod-runtime-validate flag wires emitted Zod schemas into the response parse path. When the server payload diverges from the spec, the client surfaces a schemaMismatch arm instead of silently passing bad data downstream:

atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --zod-runtime-validate

(Implies --zod β€” schemas are required for runtime validation.)

const result = await api.pets.getPet(petId);

switch (result.status) {
  case 'ok':
    // result.data: Pet (already validated against PetSchema)
    break;
  case 'schemaMismatch':
    // result.issues: ZodIssue[]
    // result.data: unknown (the raw payload)
    console.error('Spec drift detected:', result.issues);
    break;
  case 'notFound':
    // ...
}

Strict mode toggle

ApiClient exposes setStrictMode(true) so dev and staging builds throw on mismatch (immediate developer feedback), while prod logs to console and continues:

if (import.meta.env.DEV) {
  apiClient.setStrictMode(true);
}

πŸ’‘ This is unique to this generator β€” neither orval nor hey-api/openapi-ts nor kubb wires Zod into the response path automatically. Spec-drift detection comes for free as soon as the flag is on.

🎭 useSuspenseQuery Mode (--hooks-mode)

By default, --hooks ReactQuery emits useQuery calls. The --hooks-mode flag controls which React Query primitive is used:

# Default β€” Standard useQuery
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api --hooks ReactQuery

# useSuspenseQuery only β€” Suspense-boundary friendly
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --hooks ReactQuery --hooks-mode Suspense

# Both β€” canonical hook + `*Suspense` sibling for every query op
atc-rest-api-gen generate client-typescript -s api.yaml -o ./src/api \
  --hooks ReactQuery --hooks-mode Both
Mode useQuery import Options type enabled guard Hook names
Standard useQuery UseQueryOptions Yes useGetPet
Suspense useSuspenseQuery UseSuspenseQueryOptions No (incompatible) useGetPet
Both both both only on standard useGetPet + useGetPetSuspense

Mutations (useMutation) are unchanged regardless of mode β€” suspense doesn't apply to writes. Streaming hooks (useXxxStream) keep their custom state machine.

🌐 Multiple Servers

When the OpenAPI spec declares two or more servers: entries, the generator emits servers/Servers.ts with a typed const and a ServerName keyof union:

servers:
  - url: https://api.example.com/v1
    description: production
  - url: https://staging.api.example.com/v1
    description: staging

Generated:

// servers/Servers.ts
export const Servers = {
  production: 'https://api.example.com/v1',
  staging: 'https://staging.api.example.com/v1',
} as const;

export type ServerName = keyof typeof Servers;

Consumer usage:

const api = new ApiService(Servers.production, { /* options */ });
// new ApiService(Servers.staging, ...)
//                ^^^^^^^^^^^^^^^^ typo here β†’ TS error

Server-URL {variable} placeholders resolve to their declared default. Keys come from description via camelCase (with collision suffixing) or fall back to server1, server2, … when missing. Single-server specs skip this emission entirely β€” the existing single-baseUrl ctor argument pattern stays intact.

πŸ“¨ Webhook Callbacks (Operation callbacks:)

OpenAPI's operation-level callbacks: blocks describe API β†’ consumer push notifications. When present, the generator emits typed payload aliases under webhooks/Webhooks.ts:

paths:
  /subscriptions:
    post:
      operationId: createSubscription
      callbacks:
        onEvent:
          '{$request.body#/callbackUrl}':
            post:
              requestBody:
                content:
                  application/json:
                    schema:
                      $ref: '#/components/schemas/EventPayload'
              responses: { '200': { description: ok } }

Generated:

// webhooks/Webhooks.ts
import type { EventPayload } from '../models';

export type CreateSubscriptionOnEventPayload = EventPayload;

Consumers writing a webhook handler get a typed req.body as CreateSubscriptionOnEventPayload shape without redeclaring it. Inline (non-$ref) callback bodies are skipped β€” they have no canonical name to alias.

πŸ’‘ This is distinct from the OpenAPI 3.1 webhooks: root element. See Working with Webhooks for that (server-side) flavor.

πŸ”Œ SignalR Hub Hooks (x-signalr-hubs)

The doc-level vendor extension x-signalr-hubs declares typed SignalR hubs. When present, the generator emits one use<HubKey>Hub.ts per declared hub under hubs/, plus a hubs/index.ts barrel that's wired into the root api/index.ts.

# OpenAPI doc root
x-signalr-hubs:
  notifications:
    url: /hubs/notifications
    events:
      - name: SystemNotification
        payload: '#/components/schemas/SystemNotification'
      - name: UserActivity
        payload: '#/components/schemas/UserActivityEvent'

⚠️ payload is a plain string containing the schema ref path (#/components/schemas/<Name>), not a $ref: object. The generator extracts the last path segment as the TypeScript type name. Events without a payload get an unknown parameter type.

Generated hook:

const { connectionState, isConnected, connect, disconnect } = useNotificationsHub({
  onSystemNotification: (event) => {
    // event is fully typed as SystemNotification
    toast.info(event.message);
  },
  onUserActivity: (event) => {
    // event is fully typed as UserActivityEvent
    console.log(event.actorId, event.action);
  },
});
  • Uses @microsoft/signalr's HubConnectionBuilder with withAutomaticReconnect()
  • StrictMode-safe (only mutates state when the active connection ref hasn't been replaced)
  • URL composes with ApiClient.baseUrl from the ApiProvider context β€” same environment wiring as the rest of the generated client
  • Events without a payload ref type as unknown
  • Tears down in useEffect cleanup

πŸ’‘ No other OpenAPI TypeScript generator emits this. The Showcase app uses a hand-written useNotificationHub.ts today β€” migrating it to the generated equivalent is on the follow-up list.

🎭 Discriminated oneOf / anyOf Unions

When a schema uses oneOf or anyOf (with or without discriminator), the generator emits a parent union type so TypeScript narrows correctly:

Payment:
  oneOf:
    - $ref: '#/components/schemas/CreditCard'
    - $ref: '#/components/schemas/BankTransfer'
    - $ref: '#/components/schemas/PayPal'
  discriminator:
    propertyName: type

Generated:

export interface CreditCard { type: 'credit_card'; /* ... */ }
export interface BankTransfer { type: 'bank_transfer'; /* ... */ }
export interface PayPal { type: 'paypal'; /* ... */ }

export type Payment = CreditCard | BankTransfer | PayPal;

Each leaf gets its discriminator key narrowed to a literal type so consumers can write:

function describe(payment: Payment): string {
  switch (payment.type) {
    case 'credit_card': return `Card ending in ${payment.last4}`;
    case 'bank_transfer': return `Bank ${payment.routingNumber}`;
    case 'paypal': return `PayPal ${payment.accountEmail}`;
  }
}

πŸ“‘ Webhooks & Real-Time

If your OpenAPI spec defines webhooks, the generator produces typed models for each webhook payload. These models can then be consumed with any real-time transport β€” SignalR, WebSockets, or Server-Sent Events.

Generated webhook models

The Showcase spec defines three webhooks, which produce these interfaces:

// models/SystemNotification.ts
export interface SystemNotification {
  readonly id: string;
  readonly type: NotificationType;
  readonly severity?: NotificationSeverity;
  readonly timestamp: string;
  readonly message: string;
  readonly data?: string | null;
  readonly metrics?: SystemMetrics;
}

Using with SignalR

The generated models give you type safety when handling SignalR hub events:

import { HubConnectionBuilder } from '@microsoft/signalr';
import type { SystemNotification } from './api/models/SystemNotification';
import type { UserActivityEvent } from './api/models/UserActivityEvent';

const connection = new HubConnectionBuilder()
  .withUrl('/hubs/notifications')
  .withAutomaticReconnect()
  .build();

connection.on('SystemNotification', (notification: SystemNotification) => {
  console.log(notification.message, notification.severity);
});

await connection.start();

πŸ’‘ See the Showcase Demo React app for a complete SignalR integration example with connect/disconnect lifecycle management and topic-based subscriptions.

✨ New: if your spec declares the x-signalr-hubs vendor extension, the generator now emits typed useNotificationsHub / useXxxHub hooks automatically β€” see SignalR Hub Hooks (x-signalr-hubs) above.

πŸ”„ npm Script for Re-generation

Add a script to package.json so the team can regenerate the client after OpenAPI spec changes:

{
  "scripts": {
    "generate-api": "dotnet run --project path/to/Atc.Rest.Api.Generator.Cli.csproj -- generate client-typescript -s ../api.yaml -o src/api --hooks ReactQuery"
  }
}

Then simply run:

npm run generate-api

πŸ’‘ The generated files are prefixed with // <auto-generated /> β€” you should commit them to source control and regenerate when the spec changes. Do not edit them by hand.

🏠 Home

πŸ’Ό Why This Tool?

πŸ“– Getting Started

βš™οΈ Features

🌐 Frontend

πŸ“‹ Reference


πŸ”— Resources

Clone this wiki locally