Skip to content

Commit 41fe75a

Browse files
committed
typed error handling
1 parent 7e3c452 commit 41fe75a

File tree

9 files changed

+252
-58
lines changed

9 files changed

+252
-58
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,37 @@ const { status, data: pets } = await findPetsByStatus({
6868
console.log(pets[0])
6969
```
7070

71+
### Typed Error Handling
72+
73+
A non-ok fetch response throws a generic `ApiError`
74+
75+
But an Openapi document can declare a different response type for each status code, or a default error response type
76+
77+
These can be accessed via a `discriminated union` on status, as in code snippet below
78+
79+
```ts
80+
const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create()
81+
const addPet = fetcher.path('/pet').method('post').create()
82+
83+
try {
84+
await findPetsByStatus({ ... })
85+
await addPet({ ... })
86+
} catch(e) {
87+
// check which operation threw the exception
88+
if (e instanceof addPet.Error) {
89+
// get discriminated union { status, data }
90+
const error = e.getActualType()
91+
if (error.status === 400) {
92+
error.data.validationErrors // only available for a 400 response
93+
} else if (error.status === 500) {
94+
error.data.errorMessage // only available for a 500 response
95+
} else {
96+
...
97+
}
98+
}
99+
}
100+
```
101+
71102
### Middleware
72103

73104
Middlewares can be used to pre and post process fetch operations (log api calls, add auth headers etc)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "openapi-typescript-fetch",
33
"description": "A typed fetch client for openapi-typescript",
4-
"version": "1.0.1",
4+
"version": "1.1.0",
55
"engines": {
66
"node": ">= 12.0.0",
77
"npm": ">= 7.0.0"

src/fetcher.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
FetchConfig,
88
Method,
99
Middleware,
10+
OpArgType,
1011
OpenapiPaths,
12+
OpErrorType,
1113
Request,
14+
_TypedFetch,
15+
TypedFetch,
1216
} from './types'
1317

1418
const sendBody = (method: Method) =>
@@ -185,6 +189,34 @@ async function fetchUrl<R>(request: Request) {
185189
return response as ApiResponse<R>
186190
}
187191

192+
function createFetch<OP>(fetch: _TypedFetch<OP>): TypedFetch<OP> {
193+
const fun = async (payload: OpArgType<OP>, init?: RequestInit) => {
194+
try {
195+
return await fetch(payload, init)
196+
} catch (err) {
197+
if (err instanceof ApiError) {
198+
throw new fun.Error(err)
199+
}
200+
throw err
201+
}
202+
}
203+
204+
fun.Error = class extends ApiError {
205+
constructor(error: ApiError) {
206+
super(error)
207+
Object.setPrototypeOf(this, new.target.prototype)
208+
}
209+
getActualType() {
210+
return {
211+
status: this.status,
212+
data: this.data,
213+
} as OpErrorType<OP>
214+
}
215+
}
216+
217+
return fun
218+
}
219+
188220
function fetcher<Paths>() {
189221
let baseUrl = ''
190222
let defaultInit: RequestInit = {}
@@ -201,16 +233,18 @@ function fetcher<Paths>() {
201233
use: (mw: Middleware) => middlewares.push(mw),
202234
path: <P extends keyof Paths>(path: P) => ({
203235
method: <M extends keyof Paths[P]>(method: M) => ({
204-
create: ((queryParams?: Record<string, true | 1>) => (payload, init) =>
205-
fetchUrl({
206-
baseUrl: baseUrl || '',
207-
path: path as string,
208-
method: method as Method,
209-
queryParams: Object.keys(queryParams || {}),
210-
payload,
211-
init: mergeRequestInit(defaultInit, init),
212-
fetch,
213-
})) as CreateFetch<M, Paths[P][M]>,
236+
create: ((queryParams?: Record<string, true | 1>) =>
237+
createFetch((payload, init) =>
238+
fetchUrl({
239+
baseUrl: baseUrl || '',
240+
path: path as string,
241+
method: method as Method,
242+
queryParams: Object.keys(queryParams || {}),
243+
payload,
244+
init: mergeRequestInit(defaultInit, init),
245+
fetch,
246+
}),
247+
)) as CreateFetch<M, Paths[P][M]>,
214248
}),
215249
}),
216250
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import type {
44
ApiResponse,
55
FetchArgType,
66
FetchReturnType,
7+
FetchErrorType,
78
Middleware,
89
OpArgType,
10+
OpErrorType,
911
OpDefaultReturnType,
1012
OpReturnType,
1113
} from './types'
@@ -14,10 +16,12 @@ import { ApiError } from './types'
1416

1517
export type {
1618
OpArgType,
19+
OpErrorType,
1720
OpDefaultReturnType,
1821
OpReturnType,
1922
FetchArgType,
2023
FetchReturnType,
24+
FetchErrorType,
2125
ApiResponse,
2226
Middleware,
2327
}

src/types.ts

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,51 @@ export type OpArgType<OP> = OP extends {
2929
? P & Q & (B extends Record<string, unknown> ? B[keyof B] : unknown) & RB
3030
: Record<string, never>
3131

32-
export type OpDefaultReturnType<OP> = OP extends {
33-
responses: {
34-
default: infer D
35-
}
32+
type OpResponseTypes<OP> = OP extends {
33+
responses: infer R
3634
}
37-
? D extends { schema: infer S }
38-
? S
39-
: D extends { content: { 'application/json': infer C } } // openapi 3
40-
? C
41-
: D
35+
? {
36+
[S in keyof R]: R[S] extends { schema?: infer S } // openapi 2
37+
? S
38+
: R[S] extends { content: { 'application/json': infer C } } // openapi 3
39+
? C
40+
: S extends 'default'
41+
? R[S]
42+
: unknown
43+
}
44+
: never
45+
46+
type _OpReturnType<T> = 200 extends keyof T
47+
? T[200]
48+
: 'default' extends keyof T
49+
? T['default']
4250
: unknown
4351

44-
export type OpReturnType<OP> = OP extends {
45-
responses: {
46-
200: {
47-
schema?: infer R
48-
// openapi 3
49-
content?: {
50-
'application/json': infer C
51-
}
52-
}
52+
export type OpReturnType<OP> = _OpReturnType<OpResponseTypes<OP>>
53+
54+
type _OpDefaultReturnType<T> = 'default' extends keyof T
55+
? T['default']
56+
: unknown
57+
58+
export type OpDefaultReturnType<OP> = _OpDefaultReturnType<OpResponseTypes<OP>>
59+
60+
// private symbol to prevent narrowing on "default" error status
61+
const never: unique symbol = Symbol()
62+
63+
type _OpErrorType<T> = {
64+
[S in Exclude<keyof T, 200>]: {
65+
status: S extends 'default' ? typeof never : S
66+
data: T[S]
5367
}
54-
}
55-
? R & C
56-
: OpDefaultReturnType<OP>
68+
}[Exclude<keyof T, 200>]
69+
70+
type Coalesce<T, D> = [T] extends [never] ? D : T
71+
72+
// coalesce default error type to unknown
73+
export type OpErrorType<OP> = Coalesce<
74+
_OpErrorType<OpResponseTypes<OP>>,
75+
unknown
76+
>
5777

5878
export type CustomRequestInit = Omit<RequestInit, 'headers'> & {
5979
readonly headers: Headers
@@ -64,18 +84,32 @@ export type Fetch = (
6484
init: CustomRequestInit,
6585
) => Promise<ApiResponse>
6686

67-
export type TypedFetch<R, A> = (
68-
arg: A,
87+
export type _TypedFetch<OP> = (
88+
arg: OpArgType<OP>,
6989
init?: RequestInit,
70-
) => Promise<ApiResponse<R>>
90+
) => Promise<ApiResponse<OpReturnType<OP>>>
91+
92+
export type TypedFetch<OP> = _TypedFetch<OP> & {
93+
Error: new (error: ApiError) => ApiError & {
94+
getActualType: () => OpErrorType<OP>
95+
}
96+
}
97+
98+
export type FetchArgType<F> = F extends TypedFetch<infer OP>
99+
? OpArgType<OP>
100+
: never
71101

72-
export type FetchArgType<F> = F extends TypedFetch<any, infer A> ? A : never
102+
export type FetchReturnType<F> = F extends TypedFetch<infer OP>
103+
? OpReturnType<OP>
104+
: never
73105

74-
export type FetchReturnType<F> = F extends TypedFetch<infer R, any> ? R : never
106+
export type FetchErrorType<F> = F extends TypedFetch<infer OP>
107+
? OpErrorType<OP>
108+
: never
75109

76110
type _CreateFetch<OP, Q = never> = [Q] extends [never]
77-
? () => TypedFetch<OpReturnType<OP>, OpArgType<OP>>
78-
: (query: Q) => TypedFetch<OpReturnType<OP>, OpArgType<OP>>
111+
? () => TypedFetch<OP>
112+
: (query: Q) => TypedFetch<OP>
79113

80114
export type CreateFetch<M, OP> = M extends 'post' | 'put' | 'patch' | 'delete'
81115
? OP extends { parameters: { query: infer Q } }
@@ -121,7 +155,7 @@ export class ApiError extends Error {
121155
readonly statusText: string
122156
readonly data: any
123157

124-
constructor(response: ApiResponse) {
158+
constructor(response: Omit<ApiResponse, 'ok'>) {
125159
super(response.statusText)
126160
Object.setPrototypeOf(this, new.target.prototype)
127161

test/fetch.test.ts

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,16 @@ describe('fetch', () => {
8585
})
8686

8787
it('GET /error', async () => {
88-
expect.assertions(1)
88+
expect.assertions(3)
8989

90-
const fun = fetcher.path('/error').method('get').create()
90+
const fun = fetcher.path('/error/{status}').method('get').create()
9191

9292
try {
93-
await fun({})
93+
await fun({ status: 400 })
9494
} catch (err) {
95+
expect(err instanceof ApiError).toBe(true)
96+
expect(err instanceof fun.Error).toBe(true)
97+
9598
if (err instanceof ApiError) {
9699
expect(err).toMatchObject({
97100
status: 400,
@@ -103,25 +106,63 @@ describe('fetch', () => {
103106
})
104107

105108
it('GET /error (json body)', async () => {
109+
const fun = fetcher.path('/error/{status}').method('get').create()
110+
111+
const errors = {
112+
badRequest: false,
113+
internalServer: false,
114+
other: false,
115+
}
116+
117+
const handleError = (e: any) => {
118+
if (e instanceof fun.Error) {
119+
const error = e.getActualType()
120+
// discriminated union
121+
if (error.status === 400) {
122+
errors.badRequest = error.data.badRequest
123+
} else if (error.status === 500) {
124+
errors.internalServer = error.data.internalServer
125+
} else {
126+
errors.other = error.data.message === 'unknown error'
127+
}
128+
}
129+
}
130+
131+
for (const status of [400, 500, 503]) {
132+
try {
133+
await fun({ status, detail: true })
134+
} catch (e) {
135+
handleError(e)
136+
}
137+
}
138+
139+
expect(errors).toEqual({
140+
badRequest: true,
141+
internalServer: true,
142+
other: true,
143+
})
144+
})
145+
146+
it('network error', async () => {
106147
expect.assertions(1)
107148

108-
const fun = fetcher.path('/error').method('get').create()
149+
const fun = fetcher.path('/networkerror').method('get').create()
109150

110151
try {
111-
await fun({ detail: true })
152+
await fun({})
112153
} catch (e) {
113-
if (e instanceof ApiError) {
114-
expect(e).toMatchObject({
115-
status: 400,
116-
statusText: 'Bad Request',
117-
data: {
118-
message: 'Really Bad Request',
119-
},
120-
})
121-
}
154+
expect(e).not.toBeInstanceOf(ApiError)
122155
}
123156
})
124157

158+
it('operation specific error type', () => {
159+
const one = fetcher.path('/query/{a}/{b}').method('get').create()
160+
const two = fetcher.path('/body/{id}').method('post').create()
161+
162+
expect(new one.Error({} as any)).not.toBeInstanceOf(two.Error)
163+
expect(new two.Error({} as any)).not.toBeInstanceOf(one.Error)
164+
})
165+
125166
it('override init', async () => {
126167
const fun = fetcher.path('/query/{a}/{b}').method('get').create()
127168

0 commit comments

Comments
 (0)