Skip to content

Commit 7a95a55

Browse files
committed
refactor: ai slop + allow withResponse/throwOnStatusError on tanstack mutations
test: add integration tests for the tanstack runtime feat: expose runtime status codes + new InferResponseByStatus type ci: fix
1 parent d286060 commit 7a95a55

File tree

9 files changed

+180
-88
lines changed

9 files changed

+180
-88
lines changed

.changeset/true-lemons-think.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
Add comprehensive type-safe error handling and configurable status codes
66

7-
- **Type-safe error handling**: Added discriminated unions for API responses with `SafeApiResponse` and `TypedApiResponse` types that distinguish between success and error responses based on HTTP status codes
7+
- **Type-safe error handling**: Added discriminated unions for API responses with `SafeApiResponse` and `InferResponseByStatus` types that distinguish between success and error responses based on HTTP status codes
88
- **TypedResponseError class**: Introduced `TypedResponseError` that extends the native Error class to include typed response data for easier error handling
99
- Expose `successStatusCodes` and `errorStatusCodes` arrays on the generated API client instance for runtime access
1010
- **withResponse parameter**: Enhanced API clients to optionally return both the parsed data and the original Response object for advanced use cases
1111
- **throwOnStatusError option**: Added `throwOnStatusError` option to automatically throw `TypedResponseError` for error status codes, simplifying error handling in async/await patterns, defaulting to `true` (unless `withResponse` is set to true)
12-
- **TanStack Query integration**: Added complete TanStack Query client generation with:
12+
- **TanStack Query integration**: The above features are fully integrated into the TanStack Query client generator:
1313
- Advanced mutation options supporting `withResponse` and `selectFn` parameters
1414
- Automatic error type inference based on OpenAPI error schemas instead of generic Error type
1515
- Type-safe error handling with discriminated unions for mutations
@@ -21,7 +21,7 @@ Add comprehensive type-safe error handling and configurable status codes
2121
- **Enhanced CLI options**: Added new command-line options for better control:
2222
- `--include-client` to control whether to generate API client types and implementation
2323
- `--include-client=false` to only generate the schemas and endpoints
24-
- **Enhanced types**: Renamed `StatusCode` to `TStatusCode` and added reusable `ErrorStatusCode` type
24+
- **Enhanced types**: expose `SuccessStatusCode` / `ErrorStatusCode` type and their matching runtime typed arrays
2525
- **Comprehensive documentation**: Added detailed examples and guides for error handling patterns
2626

2727
This release significantly improves the type safety and flexibility of generated API clients, especially for error handling scenarios.

.github/workflows/build-and-test.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ jobs:
2424
run: pnpm build
2525

2626
- name: Run integration test (MSW)
27-
run: pnpm test:runtime
27+
run: pnpm -F typed-openapi test:runtime
2828

2929
- name: Type check generated client and integration test
30-
run: pnpm exec tsc --noEmit tmp/generated-client.ts tests/integration-runtime-msw.test.ts
30+
run: pnpm --filter typed-openapi exec tsc -b ./tsconfig.ci.json
3131

3232
- name: Test
3333
run: pnpm test

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
"test": "cd packages/typed-openapi && pnpm run test"
1515
},
1616
"devDependencies": {
17-
"@changesets/cli": "^2.29.4",
18-
"msw": "2.10.5"
17+
"@changesets/cli": "^2.29.4"
1918
},
2019
"packageManager": "[email protected]+sha256.dae0f7e822c56b20979bb5965e3b73b8bdabb6b8b8ef121da6d857508599ca35"
2120
}

packages/typed-openapi/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
"dev": "tsup --watch",
2323
"build": "tsup",
2424
"test": "vitest",
25-
"test:runtime": "pnpm run generate:runtime && pnpm run test:runtime:run",
26-
"generate:runtime": "node bin.js ./tests/samples/petstore.yaml --output ./tmp/generated-client.ts",
25+
"generate:runtime": "node bin.js ./tests/samples/petstore.yaml --output ./tmp/generated-client.ts --tanstack generated-tanstack.ts",
2726
"test:runtime:run": "vitest run tests/integration-runtime-msw.test.ts",
27+
"test:runtime": "pnpm run generate:runtime && pnpm run test:runtime:run",
2828
"fmt": "prettier --write src",
2929
"typecheck": "tsc -b ./tsconfig.build.json"
3030
},
@@ -41,6 +41,7 @@
4141
},
4242
"devDependencies": {
4343
"@changesets/cli": "^2.29.4",
44+
"@tanstack/react-query": "5.85.0",
4445
"@types/node": "^22.15.17",
4546
"@types/prettier": "3.0.0",
4647
"msw": "2.10.5",

packages/typed-openapi/src/generator.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,6 @@ const generateApiClient = (ctx: GeneratorContext) => {
303303
const { endpointList } = ctx;
304304
const byMethods = groupBy(endpointList, "method");
305305

306-
// Generate the StatusCode type from the configured success status codes
307-
const generateStatusCodeType = (statusCodes: readonly number[]) => {
308-
return statusCodes.join(" | ");
309-
};
310-
311-
const statusCodeType = generateStatusCodeType(ctx.successStatusCodes);
312-
313306
const apiClientTypes = `
314307
// <ApiClientTypes>
315308
export type EndpointParameters = {
@@ -349,11 +342,11 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
349342
350343
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
351344
352-
const successStatusCodes = [${ctx.successStatusCodes.join(",")}];
353-
type SuccessStatusCode = typeof successStatusCodes[number];
345+
export const successStatusCodes = [${ctx.successStatusCodes.join(",")}] as const;
346+
export type SuccessStatusCode = typeof successStatusCodes[number];
354347
355-
const errorStatusCodes = [${ctx.errorStatusCodes.join(",")}];
356-
type ErrorStatusCode = typeof errorStatusCodes[number];
348+
export const errorStatusCodes = [${ctx.errorStatusCodes.join(",")}] as const;
349+
export type ErrorStatusCode = typeof errorStatusCodes[number];
357350
358351
// Error handling types
359352
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
@@ -399,6 +392,8 @@ export type SafeApiResponse<TEndpoint> = TEndpoint extends { response: infer TSu
399392
? SuccessResponse<TSuccess, number>
400393
: never;
401394
395+
export type InferResponseByStatus<TEndpoint, TStatusCode> = Extract<SafeApiResponse<TEndpoint>, { status: TStatusCode }>
396+
402397
type RequiredKeys<T> = {
403398
[P in keyof T]-?: undefined extends T[P] ? never : P;
404399
}[keyof T];
@@ -484,7 +479,7 @@ export class ApiClient {
484479
json: () => Promise.resolve(data)
485480
}) as SafeApiResponse<TEndpoint>;
486481
487-
if (throwOnStatusError && errorStatusCodes.includes(response.status)) {
482+
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
488483
throw new TypedResponseError(typedResponse as never);
489484
}
490485

packages/typed-openapi/src/tanstack-query.generator.ts

Lines changed: 30 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,6 @@ import { capitalize } from "pastable/server";
22
import { prettify } from "./format.ts";
33
import type { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts";
44

5-
// Default error status codes (4xx and 5xx ranges)
6-
export const DEFAULT_ERROR_STATUS_CODES = [
7-
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424,
8-
425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
9-
] as const;
10-
11-
export type ErrorStatusCode = (typeof DEFAULT_ERROR_STATUS_CODES)[number];
12-
135
type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints>;
146
type GeneratorContext = Required<GeneratorOptions> & {
157
errorStatusCodes?: readonly number[];
@@ -18,12 +10,10 @@ type GeneratorContext = Required<GeneratorOptions> & {
1810
export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relativeApiClientPath: string }) => {
1911
const endpointMethods = new Set(ctx.endpointList.map((endpoint) => endpoint.method.toLowerCase()));
2012

21-
// Use configured error status codes or default
22-
const errorStatusCodes = ctx.errorStatusCodes ?? DEFAULT_ERROR_STATUS_CODES;
23-
2413
const file = `
2514
import { queryOptions } from "@tanstack/react-query"
26-
import type { EndpointByMethod, ApiClient, SafeApiResponse } from "${ctx.relativeApiClientPath}"
15+
import type { EndpointByMethod, ApiClient, SuccessStatusCode, ErrorStatusCode, InferResponseByStatus } from "${ctx.relativeApiClientPath}"
16+
import { errorStatusCodes, TypedResponseError } from "${ctx.relativeApiClientPath}"
2717
2818
type EndpointQueryKey<TOptions extends EndpointParameters> = [
2919
TOptions & {
@@ -76,8 +66,6 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
7666
7767
type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [config: T];
7868
79-
type ErrorStatusCode = ${errorStatusCodes.join(" | ")};
80-
8169
// </ApiClientTypes>
8270
8371
// <ApiClient>
@@ -97,6 +85,7 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
9785
/** type-only property if you need easy access to the endpoint params */
9886
"~endpoint": {} as TEndpoint,
9987
queryKey,
88+
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
10089
queryOptions: queryOptions({
10190
queryFn: async ({ queryKey, signal, }) => {
10291
const requestParams = {
@@ -110,6 +99,7 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
11099
},
111100
queryKey: queryKey
112101
}),
102+
mutationFn: {} as "You need to pass .mutationOptions to the useMutation hook",
113103
mutationOptions: {
114104
mutationKey: queryKey,
115105
mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters} ? Parameters: never) => {
@@ -134,84 +124,65 @@ export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relati
134124
135125
// <ApiClient.request>
136126
/**
137-
* Generic mutation method with full type-safety for any endpoint that doesnt require parameters to be passed initially
127+
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially
128+
* but instead will require them to be passed when calling the mutation.mutate() method
138129
*/
139130
mutation<
140131
TMethod extends keyof EndpointByMethod,
141132
TPath extends keyof EndpointByMethod[TMethod],
142133
TEndpoint extends EndpointByMethod[TMethod][TPath],
143134
TWithResponse extends boolean = false,
144135
TSelection = TWithResponse extends true
145-
? SafeApiResponse<TEndpoint>
136+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
146137
: TEndpoint extends { response: infer Res } ? Res : never,
147138
TError = TEndpoint extends { responses: infer TResponses }
148139
? TResponses extends Record<string | number, unknown>
149-
? {
150-
[K in keyof TResponses]: K extends string
151-
? K extends \`\${infer TStatusCode extends number}\`
152-
? TStatusCode extends ErrorStatusCode
153-
? Omit<Response, 'status'> & { status: TStatusCode; data: TResponses[K] }
154-
: never
155-
: never
156-
: K extends number
157-
? K extends ErrorStatusCode
158-
? Omit<Response, 'status'> & { status: K; data: TResponses[K] }
159-
: never
160-
: never;
161-
}[keyof TResponses]
140+
? InferResponseByStatus<TEndpoint, ErrorStatusCode>
162141
: Error
163142
: Error
164143
>(method: TMethod, path: TPath, options?: {
165144
withResponse?: TWithResponse;
166145
selectFn?: (res: TWithResponse extends true
167-
? SafeApiResponse<TEndpoint>
146+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
168147
: TEndpoint extends { response: infer Res } ? Res : never
169148
) => TSelection;
149+
throwOnStatusError?: boolean
170150
}) {
171151
const mutationKey = [{ method, path }] as const;
172152
return {
173153
/** type-only property if you need easy access to the endpoint params */
174154
"~endpoint": {} as TEndpoint,
175155
mutationKey: mutationKey,
156+
mutationFn: {} as "You need to pass .mutationOptions to the useMutation hook",
176157
mutationOptions: {
177158
mutationKey: mutationKey,
178-
mutationFn: async (params: TEndpoint extends { parameters: infer Parameters } ? Parameters : never): Promise<TSelection> => {
179-
const withResponse = options?.withResponse ?? false;
159+
mutationFn: async <TLocalWithResponse extends boolean = TWithResponse, TLocalSelection = TLocalWithResponse extends true
160+
? InferResponseByStatus<TEndpoint, SuccessStatusCode>
161+
: TEndpoint extends { response: infer Res }
162+
? Res
163+
: never>
164+
(params: (TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
165+
withResponse?: TLocalWithResponse;
166+
throwOnStatusError?: boolean;
167+
}): Promise<TLocalSelection> => {
168+
const withResponse = params.withResponse ??options?.withResponse ?? false;
169+
const throwOnStatusError = params.throwOnStatusError ?? options?.throwOnStatusError ?? (withResponse ? false : true);
180170
const selectFn = options?.selectFn;
171+
const response = await (this.client as any)[method](path, { ...params as any, withResponse: true, throwOnStatusError: false });
181172
182-
if (withResponse) {
183-
// Type assertion is safe because we're handling the method dynamically
184-
const response = await (this.client as any)[method](path, { ...params as any, withResponse: true });
185-
if (!response.ok) {
186-
// Create a Response-like error object with additional data property
187-
const error = Object.assign(Object.create(Response.prototype), {
188-
...response,
189-
data: response.data
190-
}) as TError;
191-
throw error;
192-
}
193-
const res = selectFn ? selectFn(response as any) : response;
194-
return res as TSelection;
195-
}
196-
197-
// Type assertion is safe because we're handling the method dynamically
198-
// Always get the full response for error handling, even when withResponse is false
199-
const response = await (this.client as any)[method](path, { ...params as any, withResponse: true });
200-
if (!response.ok) {
201-
// Create a Response-like error object with additional data property
202-
const error = Object.assign(Object.create(Response.prototype), {
203-
...response,
204-
data: response.data
205-
}) as TError;
206-
throw error;
173+
if (throwOnStatusError && errorStatusCodes.includes(response.status as never)) {
174+
throw new TypedResponseError(response as never);
207175
}
208176
209177
// Return just the data if withResponse is false, otherwise return the full response
210178
const finalResponse = withResponse ? response : response.data;
211179
const res = selectFn ? selectFn(finalResponse as any) : finalResponse;
212-
return res as TSelection;
180+
return res as never;
213181
}
214-
} as import("@tanstack/react-query").UseMutationOptions<TSelection, TError, TEndpoint extends { parameters: infer Parameters } ? Parameters : never>,
182+
} satisfies import("@tanstack/react-query").UseMutationOptions<TSelection, TError, (TEndpoint extends { parameters: infer Parameters } ? Parameters : {}) & {
183+
withResponse?: boolean;
184+
throwOnStatusError?: boolean;
185+
}>,
215186
}
216187
}
217188
// </ApiClient.request>

0 commit comments

Comments
 (0)