Skip to content

Commit 96b665a

Browse files
committed
feat: TypedResponseError + expose successStatusCodes/errorStatusCodes
1 parent 22df339 commit 96b665a

File tree

5 files changed

+90
-45
lines changed

5 files changed

+90
-45
lines changed

.changeset/true-lemons-think.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
Add comprehensive type-safe error handling and configurable status codes
66

77
- **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
8+
- **TypedResponseError class**: Introduced `TypedResponseError` that extends the native Error class to include typed response data for easier error handling
9+
- Expose `successStatusCodes` and `errorStatusCodes` arrays on the generated API client instance for runtime access
810
- **withResponse parameter**: Enhanced API clients to optionally return both the parsed data and the original Response object for advanced use cases
11+
- **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)
912
- **TanStack Query integration**: Added complete TanStack Query client generation with:
1013
- Advanced mutation options supporting `withResponse` and `selectFn` parameters
1114
- Automatic error type inference based on OpenAPI error schemas instead of generic Error type

packages/typed-openapi/scripts.runtime.json

Lines changed: 0 additions & 8 deletions
This file was deleted.

packages/typed-openapi/src/generator.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,11 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
349349
350350
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
351351
352-
// Status code type for success responses
353-
export type SuccessStatusCode = ${statusCodeType};
352+
const successStatusCodes = [${ctx.successStatusCodes.join(",")}];
353+
type SuccessStatusCode = typeof successStatusCodes[number];
354+
355+
const errorStatusCodes = [${ctx.errorStatusCodes.join(",")}];
356+
type ErrorStatusCode = typeof errorStatusCodes[number];
354357
355358
// Error handling types
356359
/** @see https://developer.mozilla.org/en-US/docs/Web/API/Response */
@@ -406,9 +409,23 @@ type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [confi
406409
`;
407410

408411
const apiClient = `
412+
// <TypedResponseError>
413+
export class TypedResponseError extends Error {
414+
response: ErrorResponse<unknown, ErrorStatusCode>;
415+
status: number;
416+
constructor(response: ErrorResponse<unknown, ErrorStatusCode>) {
417+
super(\`HTTP \${response.status}: \${response.statusText}\`);
418+
this.name = 'TypedResponseError';
419+
this.response = response;
420+
this.status = response.status;
421+
}
422+
}
423+
// </TypedResponseError>
409424
// <ApiClient>
410425
export class ApiClient {
411426
baseUrl: string = "";
427+
successStatusCodes = successStatusCodes;
428+
errorStatusCodes = errorStatusCodes;
412429
413430
constructor(public fetcher: Fetcher) {}
414431
@@ -437,7 +454,7 @@ export class ApiClient {
437454
...params: MaybeOptionalArg<${match(ctx.runtime)
438455
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
439456
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
440-
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse?: false }>
457+
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse?: false; throwOnStatusError?: boolean }>
441458
): Promise<${match(ctx.runtime)
442459
.with("zod", "yup", () => infer(`TEndpoint["response"]`))
443460
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`)
@@ -448,7 +465,7 @@ export class ApiClient {
448465
...params: MaybeOptionalArg<${match(ctx.runtime)
449466
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
450467
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
451-
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse: true }>
468+
.otherwise(() => `TEndpoint["parameters"]`)} & { withResponse: true; throwOnStatusError?: boolean }>
452469
): Promise<SafeApiResponse<TEndpoint>>;
453470
454471
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
@@ -457,27 +474,24 @@ export class ApiClient {
457474
): Promise<any> {
458475
const requestParams = params[0];
459476
const withResponse = requestParams?.withResponse;
477+
const { withResponse: _, throwOnStatusError = withResponse ? false : true, ...fetchParams } = requestParams || {};
478+
479+
const promise = this.fetcher("${method}", this.baseUrl + path, Object.keys(fetchParams).length ? requestParams : undefined)
480+
.then(async (response) => {
481+
const data = await this.parseResponse(response);
482+
const typedResponse = Object.assign(response, {
483+
data: data,
484+
json: () => Promise.resolve(data)
485+
}) as SafeApiResponse<TEndpoint>;
486+
487+
if (throwOnStatusError && errorStatusCodes.includes(response.status)) {
488+
throw new TypedResponseError(typedResponse as never);
489+
}
460490
461-
const { withResponse: _, ...fetchParams } = requestParams || {};
462-
463-
if (withResponse) {
464-
// Don't count withResponse as params
465-
return this.fetcher("${method}", this.baseUrl + path, Object.keys(fetchParams).length ? requestParams : undefined)
466-
.then(async (response) => {
467-
// Parse the response data
468-
const data = await this.parseResponse(response);
469-
470-
// Override properties while keeping the original Response object
471-
const typedResponse = Object.assign(response, {
472-
data: data,
473-
json: () => Promise.resolve(data)
474-
});
475-
return typedResponse;
476-
});
477-
}
491+
return withResponse ? typedResponse : data;
492+
});
478493
479-
return this.fetcher("${method}", this.baseUrl + path, requestParams)
480-
.then(response => this.parseResponse(response))${match(ctx.runtime)
494+
return promise ${match(ctx.runtime)
481495
.with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`)
482496
.with(
483497
"arktype",
@@ -486,7 +500,7 @@ export class ApiClient {
486500
"valibot",
487501
() => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`,
488502
)
489-
.otherwise(() => `as Promise<TEndpoint["response"]>`)};
503+
.otherwise(() => `as Promise<TEndpoint["response"]>`)}
490504
}
491505
// </ApiClient.${method}>
492506
`
@@ -518,7 +532,7 @@ export class ApiClient {
518532
)
519533
.otherwise(() => `TEndpoint extends { parameters: infer Params } ? Params : never`)}>)
520534
: Promise<SafeApiResponse<TEndpoint>> {
521-
return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters);
535+
return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters) as Promise<SafeApiResponse<TEndpoint>>;
522536
}
523537
// </ApiClient.request>
524538
}

packages/typed-openapi/tests/api-client.example.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,12 @@ const fetcher: Fetcher = async (method, apiUrl, params) => {
6363
});
6464
}
6565

66-
const withResponse = params && typeof params === 'object' && 'withResponse' in params && params.withResponse;
6766
const response = await fetch(url, {
6867
method: method.toUpperCase(),
6968
...(body && { body }),
7069
headers,
7170
});
7271

73-
if (!response.ok && !withResponse) {
74-
// You can customize error handling here
75-
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
76-
(error as any).response = response;
77-
(error as any).status = response.status;
78-
throw error;
79-
}
80-
8172
return response;
8273
};
8374

packages/typed-openapi/tests/integration-runtime-msw.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { http, HttpResponse } from "msw";
55
import { setupServer } from "msw/node";
66
import { afterAll, beforeAll, describe, expect, it } from "vitest";
77
import { api } from "./api-client.example.js";
8-
import { createApiClient } from "../tmp/generated-client.ts";
8+
import { createApiClient, TypedResponseError } from "../tmp/generated-client.ts";
99

1010
// Mock handler for a real endpoint from petstore.yaml
1111
const mockPets = [
@@ -73,6 +73,11 @@ describe("Example API Client", () => {
7373
api.baseUrl = "http://localhost";
7474
});
7575

76+
it("has access to successStatusCodes and errorStatusCodes", async () => {
77+
expect(api.successStatusCodes).toEqual([200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308]);
78+
expect(api.errorStatusCodes).toEqual([400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511]);
79+
});
80+
7681
it("should fetch /pet/findByStatus and receive mocked pets", async () => {
7782
const result = await api.get("/pet/findByStatus", { query: {} });
7883
expect(result).toEqual(mockPets);
@@ -169,21 +174,61 @@ describe("Example API Client", () => {
169174
});
170175

171176
it("should handle error status codes as error in union (get /pet/findByStatus with error)", async () => {
172-
// Simulate error (400) for status=pending
173177
const errorRes = await api.get("/pet/findByStatus", { query: { status: "pending" }, withResponse: true });
174178
expect(errorRes.ok).toBe(false);
175179
expect(errorRes.status).toBe(400);
176180
expect(errorRes.data).toEqual({ code: 400, message: expect.any(String) });
177181
});
178182

179183
it("should support configurable status codes (simulate 201)", async () => {
180-
// Simulate a 200 response for POST /pet (MSW handler returns 200)
181184
const res = await api.post("/pet", { body: { name: "Created", photoUrls: [] }, withResponse: true });
182185
expect([200, 201]).toContain(res.status);
183186
expect(res.ok).toBe(true);
184187
if (!res.ok) throw new Error("res.ok is false");
185188

186189
expect(res.data.name).toBe("Created");
187190
});
191+
192+
it("should throw when throwOnStatusError is true with withResponse", async () => {
193+
let err: unknown;
194+
try {
195+
await api.get("/pet/{petId}", { path: { petId: 9999 }, withResponse: true, throwOnStatusError: true });
196+
} catch (e) {
197+
err = e;
198+
}
199+
200+
const error = err as TypedResponseError;
201+
expect(error).toBeInstanceOf(TypedResponseError);
202+
expect(error.message).toContain("404");
203+
expect(error.status).toBe(404);
204+
expect(error.response.data).toEqual({ code: 404, message: expect.any(String) });
205+
expect(error.response).toBeDefined();
206+
});
207+
208+
it("should not throw when throwOnStatusError is false with withResponse", async () => {
209+
const res = await api.get("/pet/{petId}", {
210+
path: { petId: 9999 },
211+
withResponse: true,
212+
throwOnStatusError: false,
213+
});
214+
expect(res.ok).toBe(false);
215+
expect(res.status).toBe(404);
216+
expect(res.data).toEqual({ code: 404, message: expect.any(String) });
217+
});
218+
219+
it("should throw by default when withResponse is not set and error status", async () => {
220+
let err: unknown;
221+
try {
222+
await api.get("/pet/{petId}", { path: { petId: 9999 } });
223+
} catch (e) {
224+
err = e as TypedResponseError;
225+
}
226+
const error = err as TypedResponseError;
227+
expect(error).toBeInstanceOf(TypedResponseError);
228+
expect(error.message).toContain("404");
229+
expect(error.status).toBe(404);
230+
expect(error.response.data).toEqual({ code: 404, message: expect.any(String) });
231+
expect(error.response).toBeDefined();
232+
});
188233
});
189234
});

0 commit comments

Comments
 (0)