-
Notifications
You must be signed in to change notification settings - Fork 1
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.
# 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| 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 |
# 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 --watchThe 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 |
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.
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);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 });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.
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.
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[]>
}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."
}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/apiUse --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 AxiosOnly 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 nativefetchin the Axios variant, since Axios lacksReadableStreamiteration support.
Both Fetch and Axios clients support request and response interceptors via ApiClientOptions:
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],
});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.
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 ReactQueryEach 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
-
useQueryhooks for GET operations (oruseSuspenseQueryunder--hooks-mode Suspense) -
useMutationhooks for POST/PUT/PATCH/DELETE with automatic cache invalidation -
useXxxStreamhooks forx-return-async-enumerableoperations (see Streaming hooks) -
useXxxInfinitehooks for paginated streaming (seeuseInfiniteQuery) -
Per-op
options?pass-through β every hook acceptsoptions?: Omit<UseQueryOptions<TData, ApiError>, 'queryKey' | 'queryFn'>(orUseMutationOptions<TData, ApiError, TVariables>for mutations) so callers can overridestaleTime,enabled,select,onSuccess, etc. without wrapping the hook. For mutations, the generator wrapsonSuccesswithqueryClient.invalidateQueries({ queryKey: <segment>Keys.all })and then forwards to the caller'sonSuccessβ the cache invalidation always runs alongside whatever the caller passes.
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>
);
}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>
);
}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') });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 SwrEach API segment gets a hook file with:
-
useSWRhooks for GET operations -
useSWRMutationhooks for POST/PUT/PATCH/DELETE -
useXxxStreamhooks forx-return-async-enumerableoperations β sameuseState/useEffect/useRef/AbortControllershape as the React Query variant (the streaming hook is framework-agnostic React code)
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>
);
}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
swras a peer dependency. Install it alongside React:npm install swr.
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 --mswEach 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.tsbarrel exists specifically so the rootapi/index.tscanexport * from './mocks'under stricttsc -bwithout aTS2307: Cannot find moduleerror.
import { setupServer } from 'msw/node';
import { handlers } from './api/mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());import { setupWorker } from 'msw/browser';
import { handlers } from './api/mocks/handlers';
const worker = setupWorker(...handlers);
worker.start();π‘
--mswcan be combined with--hooksto get both hooks and mocks in a single generation pass.
The generator automatically produces retry infrastructure when it detects x-retry-* extensions in the OpenAPI spec (see Working with Resilience for extension syntax).
| File | Purpose |
|---|---|
helpers/retryConfig.ts |
RetryPolicy interface and per-operation policy constants |
helpers/retryInterceptor.ts |
retryWithBackoff() function wired into every request |
interface RetryPolicy {
readonly maxAttempts: number;
readonly delayMs: number;
readonly backoff: 'constant' | 'linear' | 'exponential';
readonly useJitter: boolean;
readonly timeoutMs?: number;
}- Retries are automatic for
429 Too Many Requests(respectsRetry-Afterheader) and5xxerrors - Supports
AbortSignalfor cancellation during retry delays - Exponential backoff with optional jitter prevents thundering herd
- Each operation maps to its own policy via the generated
operationPoliciesmap
π‘ No
x-retry-*extensions? No retry files are generated β the client stays minimal.
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;
}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');
}
},
});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;
}
}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>
);
}The generated client handles multipart/form-data and application/octet-stream automatically.
const file = document.getElementById('fileInput') as HTMLInputElement;
const result = await api.files.uploadSingleFileAsFormData(file.files![0]);const result = await api.files.uploadSingleObjectWithFileAsFormData({
itemName: 'photo.jpg',
file: file.files![0],
items: ['tag1', 'tag2'],
});const result = await api.files.uploadMultiFilesAsFormData(
Array.from(file.files!)
);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);
}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 setsresponseType: 'text'. Both strip the JSON code path so content types liketext/csvround-trip without being mangled by a JSON parser.
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;
}
}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.
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:
-
Non-streaming client companion
<methodName>Pageβ same path/query params plus a synthesizedheaders?: { 'x-continuation'?: string }parameter. ReturnsPromise<<MethodName>PageResult>whoseokarm carriesdata: PaginationResult<Item>. -
useXxxInfinitehook β callsuseInfiniteQuery, threads the previous page'scontinuationTokenback through thex-continuationheader onfetchNextPage, and readsgetNextPageParam: (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 }]The --enum-style flag controls how OpenAPI string enums are generated.
/** 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.
/** 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.
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.
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.
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-datesThe generated ApiClient emits dateReviver/dateReplacer functions that automatically:
- Convert ISO 8601 strings to
Dateobjects on response parsing - Convert
Dateobjects back to ISO 8601 strings on request serialization
This works for both Fetch and Axios client variants, including streaming items.
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 --zodEach 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(),
});| 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() |
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);
}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-clientDependencies are added conditionally:
-
axios(if--client-type Axios) -
zod(if--zodor--zod-runtime-validate) -
@tanstack/react-query+reactas peer deps (if--hooks ReactQuery) -
swr+reactas peer deps (if--hooks Swr) -
@microsoft/signalr(if the spec declaresx-signalr-hubs) -
mswas dev dependency (if--msw) -
@types/reactas dev dependency (if hooks enabled)
A consumer-facing README.md ships alongside the package with:
- Quick-start (including the
ApiProviderwrap 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)
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) -
exportsmap withimport/require/types/defaultconditions
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".
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.
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;
}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, dropswriteOnlyprops -
<Name>Writableβ request shape, dropsreadOnlyprops (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
Writablesuffix is intentional β it avoids colliding with explicit*Requestschemas that real specs commonly declare side-by-side with the model.
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-idsGenerated 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) anduseGetPetSwr(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.
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':
// ...
}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.
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.
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: stagingGenerated:
// 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 errorServer-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.
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.
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'
β οΈ payloadis 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 apayloadget anunknownparameter 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'sHubConnectionBuilderwithwithAutomaticReconnect() - StrictMode-safe (only mutates state when the active connection ref hasn't been replaced)
- URL composes with
ApiClient.baseUrlfrom theApiProvidercontext β same environment wiring as the rest of the generated client - Events without a
payloadref type asunknown - Tears down in
useEffectcleanup
π‘ No other OpenAPI TypeScript generator emits this. The Showcase app uses a hand-written
useNotificationHub.tstoday β migrating it to the generated equivalent is on the follow-up list.
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: typeGenerated:
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}`;
}
}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.
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;
}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-hubsvendor extension, the generator now emits typeduseNotificationsHub/useXxxHubhooks automatically β see SignalR Hub Hooks (x-signalr-hubs) above.
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
- πΌ FAQ Business Value
- π Getting Started with Basic
- π οΈ Getting Started with CLI
- π Migration Guide
- π Working with OpenAPI
- π οΈ Working with CLI
- π How-To Guides
- π Working with Security
- π¦ Working with Rate Limiting
- π Working with Resilience
- ποΈ Working with Caching
- π’ Working with Versioning
- β Working with Validations
- π Working with Webhooks
- βοΈ Working with Aspire
- π£οΈ Working with Endpoint Definitions
- π Working with Multi-Part Specs
- π§ͺ Working with Code Coverage
- π¦ Working with TypeScript Client
- πͺ Showcase Demo
- π§ͺ Working with E2E Testing
- βοΈ Working with Configuration
- π Marker Files
- π API Reference
- π Analyzer Rules
- β FAQ and Troubleshooting
- πΊοΈ Roadmap
- π§ Development Notes
- π¦ GitHub Repository
- π₯ NuGet Package
- π Report Issues