Skip to content

Commit 27c4afd

Browse files
committed
wip: fix response.error/status inference
1 parent 8978d6a commit 27c4afd

31 files changed

+411
-65
lines changed

ERROR_HANDLING.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type get_GetUserById = {
1616
response: { id: string; name: string }; // Success response (2xx)
1717
responses: {
1818
200: { id: string; name: string }; // Success
19-
401: { error: string; code: number }; // Unauthorized
19+
401: { error: string; code: number }; // Unauthorized
2020
404: { message: string }; // Not Found
2121
500: { error: string }; // Server Error
2222
};
@@ -68,11 +68,11 @@ const api = createApiClient(fetch);
6868

6969
async function getUser(id: string) {
7070
const result = await api.getSafe("/users/{id}", { path: { id } });
71-
71+
7272
if (result.ok) {
7373
return result.data; // Typed as success response
7474
}
75-
75+
7676
// Handle specific error cases
7777
switch (result.status) {
7878
case 401:
@@ -92,13 +92,13 @@ async function getUser(id: string) {
9292
```typescript
9393
async function handleApiCall<T>(apiCall: () => Promise<T>) {
9494
const result = await apiCall();
95-
95+
9696
if (result.ok) {
9797
return { success: true, data: result.data };
9898
}
99-
100-
return {
101-
success: false,
99+
100+
return {
101+
success: false,
102102
error: {
103103
status: result.status,
104104
message: getErrorMessage(result.error),

error-handling-example.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Example: How to use the type-safe error handling
2+
3+
// This is what the generated types would look like:
4+
type GetUserByIdEndpoint = {
5+
method: "GET";
6+
path: "/users/{id}";
7+
requestFormat: "json";
8+
parameters: {
9+
path: { id: string };
10+
};
11+
response: { id: string; name: string };
12+
responses: {
13+
200: { id: string; name: string };
14+
401: { error: string; code: number };
15+
404: { message: string };
16+
500: { error: string };
17+
};
18+
};
19+
20+
// The SafeApiResponse type creates a discriminated union:
21+
type SafeApiResponse<TEndpoint> = TEndpoint extends { response: infer TSuccess; responses: infer TResponses }
22+
? TResponses extends Record<string, unknown>
23+
? ApiResponse<TSuccess, TResponses>
24+
: { ok: true; status: number; data: TSuccess }
25+
: TEndpoint extends { response: infer TSuccess }
26+
? { ok: true; status: number; data: TSuccess }
27+
: never;
28+
29+
type ApiResponse<TSuccess, TErrors extends Record<string, unknown> = {}> = {
30+
ok: true;
31+
status: number;
32+
data: TSuccess;
33+
} | {
34+
[K in keyof TErrors]: {
35+
ok: false;
36+
status: K extends string ? (K extends `${number}` ? number : never) : never;
37+
error: TErrors[K];
38+
}
39+
}[keyof TErrors];
40+
41+
// Example usage:
42+
async function handleUserRequest(api: any, userId: string) {
43+
// Using the safe method for type-safe error handling
44+
const result = await api.getSafe("/users/{id}", { path: { id: userId } });
45+
46+
if (result.ok) {
47+
// TypeScript knows result.data is { id: string; name: string }
48+
console.log(`User found: ${result.data.name} (ID: ${result.data.id})`);
49+
return result.data;
50+
} else {
51+
// TypeScript knows this is an error case
52+
if (result.status === 401) {
53+
// TypeScript knows result.error is { error: string; code: number }
54+
console.error(`Authentication failed: ${result.error.error} (Code: ${result.error.code})`);
55+
throw new Error("Unauthorized");
56+
} else if (result.status === 404) {
57+
// TypeScript knows result.error is { message: string }
58+
console.error(`User not found: ${result.error.message}`);
59+
return null;
60+
} else if (result.status === 500) {
61+
// TypeScript knows result.error is { error: string }
62+
console.error(`Server error: ${result.error.error}`);
63+
throw new Error("Server error");
64+
}
65+
}
66+
}
67+
68+
// Alternative: Using traditional try/catch with status code checking
69+
async function handleUserRequestTraditional(api: any, userId: string) {
70+
try {
71+
// Using the regular method - only gets success response type
72+
const user = await api.get("/users/{id}", { path: { id: userId } });
73+
console.log(`User found: ${user.name} (ID: ${user.id})`);
74+
return user;
75+
} catch (error) {
76+
// No type safety here - error is unknown
77+
console.error("Request failed:", error);
78+
throw error;
79+
}
80+
}
81+
82+
export { handleUserRequest, handleUserRequestTraditional };

packages/typed-openapi/src/generator.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -316,17 +316,19 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
316316
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
317317
318318
// Error handling types
319-
export type ApiResponse<TSuccess, TErrors extends Record<string, unknown> = {}> = {
320-
ok: true;
321-
status: number;
322-
data: TSuccess;
323-
} | {
324-
[K in keyof TErrors]: {
325-
ok: false;
326-
status: K extends string ? (K extends \`\${number}\` ? number : never) : never;
327-
error: TErrors[K];
328-
}
329-
}[keyof TErrors];
319+
export type ApiResponse<TSuccess, TErrors extends Record<string, unknown> = {}> =
320+
| {
321+
ok: true;
322+
status: number;
323+
data: TSuccess;
324+
}
325+
| {
326+
[K in keyof TErrors]: {
327+
ok: false;
328+
status: K extends \`\${infer StatusCode extends number}\` ? StatusCode : never;
329+
error: TErrors[K];
330+
};
331+
}[keyof TErrors];
330332
331333
export type SafeApiResponse<TEndpoint> = TEndpoint extends { response: infer TSuccess; responses: infer TResponses }
332334
? TResponses extends Record<string, unknown>
@@ -382,7 +384,7 @@ export class ApiClient {
382384
.with("zod", "yup", () => infer(`TEndpoint["response"]`))
383385
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`)
384386
.otherwise(() => `TEndpoint["response"]`)}>;
385-
387+
386388
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
387389
path: Path,
388390
options: { withResponse: true },
@@ -391,7 +393,7 @@ export class ApiClient {
391393
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
392394
.otherwise(() => `TEndpoint["parameters"]`)}>
393395
): Promise<SafeApiResponse<TEndpoint>>;
394-
396+
395397
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
396398
path: Path,
397399
optionsOrParams?: { withResponse?: boolean } | ${match(ctx.runtime)
@@ -402,7 +404,7 @@ export class ApiClient {
402404
): Promise<any> {
403405
const hasWithResponse = optionsOrParams && typeof optionsOrParams === 'object' && 'withResponse' in optionsOrParams;
404406
const requestParams = hasWithResponse ? params[0] : optionsOrParams;
405-
407+
406408
if (hasWithResponse && optionsOrParams.withResponse) {
407409
return this.fetcher("${method}", this.baseUrl + path, requestParams)
408410
.then(async (response) => {
@@ -472,7 +474,7 @@ export function createApiClient(fetcher: Fetcher, baseUrl?: string) {
472474
api.get("/users").then((users) => console.log(users));
473475
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
474476
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
475-
477+
476478
// With error handling
477479
const result = await api.get("/users/{id}", { withResponse: true }, { path: { id: "123" } });
478480
if (result.ok) {

packages/typed-openapi/src/map-openapi-endpoints.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,11 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
125125
// Match the first 2xx-3xx response found, or fallback to default one otherwise
126126
let responseObject: ResponseObject | undefined;
127127
const allResponses: Record<string, AnyBox> = {};
128-
128+
129129
Object.entries(operation.responses ?? {}).map(([status, responseOrRef]) => {
130130
const statusCode = Number(status);
131131
const responseObj = refs.unwrap<ResponseObject>(responseOrRef);
132-
132+
133133
// Collect all responses for error handling
134134
const content = responseObj?.content;
135135
if (content) {
@@ -147,13 +147,13 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
147147
// If no content defined, use unknown type
148148
allResponses[status] = openApiSchemaToTs({ schema: {}, ctx });
149149
}
150-
150+
151151
// Keep the current logic for the main response (first 2xx-3xx)
152152
if (statusCode >= 200 && statusCode < 300 && !responseObject) {
153153
responseObject = responseObj;
154154
}
155155
});
156-
156+
157157
if (!responseObject && operation.responses?.default) {
158158
responseObject = refs.unwrap(operation.responses.default);
159159
// Also add default to all responses if not already covered
@@ -170,7 +170,7 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
170170
}
171171
}
172172
}
173-
173+
174174
// Set the responses collection
175175
if (Object.keys(allResponses).length > 0) {
176176
endpoint.responses = allResponses;

packages/typed-openapi/tests/generator.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ describe("generator", () => {
335335
| {
336336
[K in keyof TErrors]: {
337337
ok: false;
338-
status: K extends string ? (K extends \`\${number}\` ? number : never) : never;
338+
status: K extends \`\${infer StatusCode extends number}\` ? StatusCode : never;
339339
error: TErrors[K];
340340
};
341341
}[keyof TErrors];
@@ -558,7 +558,7 @@ describe("generator", () => {
558558
api.get("/users").then((users) => console.log(users));
559559
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
560560
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
561-
561+
562562
// With error handling
563563
const result = await api.get("/users/{id}", { withResponse: true }, { path: { id: "123" } });
564564
if (result.ok) {
@@ -920,7 +920,7 @@ describe("generator", () => {
920920
| {
921921
[K in keyof TErrors]: {
922922
ok: false;
923-
status: K extends string ? (K extends \`\${number}\` ? number : never) : never;
923+
status: K extends \`\${infer StatusCode extends number}\` ? StatusCode : never;
924924
error: TErrors[K];
925925
};
926926
}[keyof TErrors];
@@ -1032,7 +1032,7 @@ describe("generator", () => {
10321032
api.get("/users").then((users) => console.log(users));
10331033
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
10341034
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
1035-
1035+
10361036
// With error handling
10371037
const result = await api.get("/users/{id}", { withResponse: true }, { path: { id: "123" } });
10381038
if (result.ok) {
@@ -1192,7 +1192,7 @@ describe("generator", () => {
11921192
| {
11931193
[K in keyof TErrors]: {
11941194
ok: false;
1195-
status: K extends string ? (K extends \`\${number}\` ? number : never) : never;
1195+
status: K extends \`\${infer StatusCode extends number}\` ? StatusCode : never;
11961196
error: TErrors[K];
11971197
};
11981198
}[keyof TErrors];
@@ -1304,7 +1304,7 @@ describe("generator", () => {
13041304
api.get("/users").then((users) => console.log(users));
13051305
api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
13061306
api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
1307-
1307+
13081308
// With error handling
13091309
const result = await api.get("/users/{id}", { withResponse: true }, { path: { id: "123" } });
13101310
if (result.ok) {
@@ -1322,18 +1322,18 @@ describe("generator", () => {
13221322
test("error schemas", async ({ expect }) => {
13231323
const openApiDoc = (await SwaggerParser.parse("./tests/samples/error-schemas.yaml")) as OpenAPIObject;
13241324
const generated = await prettify(generateFile(mapOpenApiEndpoints(openApiDoc)));
1325-
1325+
13261326
// Verify error schemas are generated
13271327
expect(generated).toContain("export type AuthError");
13281328
expect(generated).toContain("export type NotFoundError");
13291329
expect(generated).toContain("export type ValidationError");
13301330
expect(generated).toContain("export type ForbiddenError");
13311331
expect(generated).toContain("export type ServerError");
1332-
1332+
13331333
// Verify error responses are included in endpoint types
13341334
expect(generated).toContain('responses: { 200: Schemas.User; 401: Schemas.AuthError; 404: Schemas.NotFoundError; 500: Schemas.ServerError }');
13351335
expect(generated).toContain('responses: { 201: Schemas.Post; 400: Schemas.ValidationError; 403: Schemas.ForbiddenError }');
1336-
1336+
13371337
// Verify specific error schema structure
13381338
expect(generated).toContain("error: string");
13391339
expect(generated).toContain("code: number");

packages/typed-openapi/tests/map-openapi-endpoints.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3117,7 +3117,7 @@ describe("map-openapi-endpoints", () => {
31173117
test("error schemas", async ({ expect }) => {
31183118
const openApiDoc = (await SwaggerParser.parse("./tests/samples/error-schemas.yaml")) as OpenAPIObject;
31193119
const result = mapOpenApiEndpoints(openApiDoc);
3120-
3120+
31213121
// Find the getUserById endpoint
31223122
const getUserEndpoint = result.endpointList.find(e => e.meta.alias === "get_GetUserById");
31233123
expect(getUserEndpoint).toBeDefined();
@@ -3165,7 +3165,7 @@ describe("map-openapi-endpoints", () => {
31653165
// Verify that error schemas are properly resolved
31663166
const authErrorBox = result.refs.getInfosByRef("#/components/schemas/AuthError");
31673167
expect(authErrorBox?.name).toBe("AuthError");
3168-
3168+
31693169
const validationErrorBox = result.refs.getInfosByRef("#/components/schemas/ValidationError");
31703170
expect(validationErrorBox?.name).toBe("ValidationError");
31713171
});

packages/typed-openapi/tests/samples/error-schemas.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ components:
7777
email:
7878
type: string
7979
required: [id, name, email]
80-
80+
8181
Post:
8282
type: object
8383
properties:
@@ -90,7 +90,7 @@ components:
9090
authorId:
9191
type: string
9292
required: [id, title, content, authorId]
93-
93+
9494
PostInput:
9595
type: object
9696
properties:
@@ -99,7 +99,7 @@ components:
9999
content:
100100
type: string
101101
required: [title, content]
102-
102+
103103
AuthError:
104104
type: object
105105
properties:
@@ -110,7 +110,7 @@ components:
110110
timestamp:
111111
type: string
112112
required: [error, code]
113-
113+
114114
NotFoundError:
115115
type: object
116116
properties:
@@ -119,7 +119,7 @@ components:
119119
resource:
120120
type: string
121121
required: [message, resource]
122-
122+
123123
ValidationError:
124124
type: object
125125
properties:
@@ -130,7 +130,7 @@ components:
130130
value:
131131
type: string
132132
required: [message, field]
133-
133+
134134
ForbiddenError:
135135
type: object
136136
properties:
@@ -139,7 +139,7 @@ components:
139139
reason:
140140
type: string
141141
required: [error, reason]
142-
142+
143143
ServerError:
144144
type: object
145145
properties:

packages/typed-openapi/tests/snapshots/docker.openapi.client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2593,7 +2593,7 @@ export type ApiResponse<TSuccess, TErrors extends Record<string, unknown> = {}>
25932593
| {
25942594
[K in keyof TErrors]: {
25952595
ok: false;
2596-
status: K extends string ? (K extends `${number}` ? number : never) : never;
2596+
status: K extends `${infer StatusCode extends number}` ? StatusCode : never;
25972597
error: TErrors[K];
25982598
};
25992599
}[keyof TErrors];

0 commit comments

Comments
 (0)