Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 801231d

Browse filesBrowse files
committed
fix: improve TypedHeaders inference and add test
1 parent 8325c90 commit 801231d
Copy full SHA for 801231d

2 files changed

+342-8Lines changed: 342 additions & 8 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎src/fetch.ts‎

Copy file name to clipboardExpand all lines: src/fetch.ts
+8-8Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,23 @@ import type { ResponseHeaderMap } from './http'
22

33
export interface TypedHeaders<TypedHeaderValues extends Record<string, string> | unknown> extends Omit<Headers, 'append' | 'delete' | 'get' | 'getSetCookie' | 'has' | 'set' | 'forEach'> {
44
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */
5-
append: <Name extends string = keyof TypedHeaderValues & string> (name: Name, value: Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string) => void
5+
append: <Name extends Extract<keyof TypedHeaderValues, string> | string & {}> (name: Name, value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) => void
66
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */
7-
delete: <Name extends string = keyof TypedHeaderValues & string> (name: Name) => void
7+
delete: <Name extends Extract<keyof TypedHeaderValues, string> | string & {}> (name: Name) => void
88
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */
9-
get: <Name extends string = keyof TypedHeaderValues & string> (name: Name) => (Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string) | null
9+
get: <Name extends Extract<keyof TypedHeaderValues, string> | string & {}> (name: Name) => (Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) | null
1010
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */
1111
getSetCookie: () => string[]
1212
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) */
13-
has: <Name extends string = keyof TypedHeaderValues & string> (name: Name) => boolean
13+
has: <Name extends Extract<keyof TypedHeaderValues, string> | string & {}> (name: Name) => boolean
1414
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */
15-
set: <Name extends string = keyof TypedHeaderValues & string> (name: Name, value: Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string) => void
16-
forEach: (callbackfn: <Name extends string = keyof TypedHeaderValues & string>(value: Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string, key: Name, parent: Headers) => void, thisArg?: any) => void
15+
set: <Name extends Extract<keyof TypedHeaderValues, string> | string & {}> (name: Name, value: Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string) => void
16+
forEach: (callbackfn: (value: TypedHeaderValues[keyof TypedHeaderValues] | string & {}, key: Extract<keyof TypedHeaderValues, string> | string & {}, parent: TypedHeaders<TypedHeaderValues>) => void, thisArg?: any) => void
1717
}
1818

1919
// type TypedHeaderTuples<TypedHeaderValues extends Record<string, string>> = {
20-
// [Name in Lowercase<keyof TypedHeaderValues & string>]: [Name, Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string]
21-
// }[Lowercase<keyof TypedHeaderValues & string>][]
20+
// [Name in Lowercase<Extract<keyof TypedHeaderValues, string>>]: [Name, Name extends string ? Lowercase<Name> extends keyof TypedHeaderValues ? TypedHeaderValues[Lowercase<Name>] : string : string]
21+
// }[Lowercase<Extract<keyof TypedHeaderValues, string>>][]
2222

2323
// type TypedHeadersInit<TypedHeaderValues extends Record<string, string>> = TypedHeaderTuples<TypedHeaderValues> | Partial<TypedHeaderValues> | TypedHeaders<TypedHeaderValues> | Headers
2424

Collapse file

‎test/typed-fetch.test-d.ts‎

Copy file name to clipboard
+334Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import type { TypedHeaders, TypedRequest, TypedResponse } from '../src/fetch'
2+
import type { RequestHeaderMap, ResponseHeaderMap } from '../src/http'
3+
import { describe, expectTypeOf, it } from 'vitest'
4+
5+
describe('TypedHeaders', () => {
6+
it('should type header operations correctly', () => {
7+
interface TestHeaderMap {
8+
'content-type': 'application/json' | 'text/plain'
9+
'authorization': `Bearer ${string}`
10+
}
11+
12+
type TestHeaders = TypedHeaders<TestHeaderMap>
13+
14+
// get method should return typed values or null
15+
expectTypeOf<TestHeaders['get']>().parameter(0).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
16+
expectTypeOf<TestHeaders['get']>().returns.toEqualTypeOf<string | null>()
17+
18+
// has method should accept header names
19+
expectTypeOf<TestHeaders['has']>().parameter(0).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
20+
expectTypeOf<TestHeaders['has']>().returns.toEqualTypeOf<boolean>()
21+
22+
// set method should accept typed values
23+
expectTypeOf<TestHeaders['set']>().parameter(0).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
24+
expectTypeOf<TestHeaders['set']>().parameter(1).toEqualTypeOf<string>()
25+
expectTypeOf<TestHeaders['set']>().returns.toEqualTypeOf<void>()
26+
27+
// append method should accept typed values
28+
expectTypeOf<TestHeaders['append']>().parameter(0).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
29+
expectTypeOf<TestHeaders['append']>().parameter(1).toEqualTypeOf<'application/json' | 'text/plain' | string>()
30+
expectTypeOf<TestHeaders['append']>().returns.toEqualTypeOf<void>()
31+
32+
// delete method should accept header names
33+
expectTypeOf<TestHeaders['delete']>().parameter(0).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
34+
expectTypeOf<TestHeaders['delete']>().returns.toEqualTypeOf<void>()
35+
36+
const a = {} as TestHeaders
37+
a.forEach((value, key, parent) => {
38+
expectTypeOf(parent).toEqualTypeOf<TestHeaders>()
39+
expectTypeOf(key).toEqualTypeOf<'content-type' | 'authorization' | string & {}>()
40+
if (key === 'content-type') {
41+
expectTypeOf(key).toEqualTypeOf<'content-type' | string & {}>()
42+
expectTypeOf(value).toEqualTypeOf<'application/json' | 'text/plain' | `Bearer ${string}` | string & {}>()
43+
}
44+
})
45+
})
46+
47+
it('should handle case-insensitive header names', () => {
48+
interface TestHeaderMap {
49+
'Content-Type': 'application/json'
50+
'X-Custom': string
51+
}
52+
type TestHeaders = TypedHeaders<TestHeaderMap>
53+
54+
// Should accept both cases for known headers
55+
const headers = {} as TestHeaders
56+
57+
// These should be valid calls (testing that lowercase works)
58+
expectTypeOf(headers.get).toBeCallableWith('content-type')
59+
expectTypeOf(headers.get).toBeCallableWith('Content-Type')
60+
expectTypeOf(headers.get).toBeCallableWith('x-custom')
61+
expectTypeOf(headers.get).toBeCallableWith('X-Custom')
62+
63+
// Unknown headers should fallback to string
64+
expectTypeOf(headers.get).toBeCallableWith('unknown-header')
65+
})
66+
67+
it('should preserve Headers interface methods', () => {
68+
type TestHeaders = TypedHeaders<{ 'content-type': string }>
69+
70+
// Should have all Headers methods
71+
expectTypeOf<TestHeaders>().toHaveProperty('getSetCookie')
72+
expectTypeOf<TestHeaders['getSetCookie']>().returns.toEqualTypeOf<string[]>()
73+
74+
expectTypeOf<keyof TestHeaders>().toEqualTypeOf<keyof Headers>()
75+
})
76+
77+
it('should work with unknown header types', () => {
78+
type TestHeaders = TypedHeaders<unknown>
79+
80+
// Should fallback to string for all operations
81+
expectTypeOf<TestHeaders['get']>().returns.toEqualTypeOf<string | null>()
82+
expectTypeOf<TestHeaders['set']>().parameter(1).toEqualTypeOf<string>()
83+
})
84+
85+
it('should work with standard header maps', () => {
86+
type RequestHeaders = TypedHeaders<RequestHeaderMap>
87+
type ResponseHeaders = TypedHeaders<ResponseHeaderMap>
88+
89+
// Should accept standard headers
90+
expectTypeOf<RequestHeaders['get']>().toBeCallableWith('authorization')
91+
expectTypeOf<RequestHeaders['get']>().toBeCallableWith('content-type')
92+
expectTypeOf<RequestHeaders['get']>().toBeCallableWith('user-agent')
93+
94+
expectTypeOf<ResponseHeaders['get']>().toBeCallableWith('content-type')
95+
expectTypeOf<ResponseHeaders['get']>().toBeCallableWith('cache-control')
96+
expectTypeOf<ResponseHeaders['get']>().toBeCallableWith('set-cookie')
97+
})
98+
})
99+
100+
describe('TypedResponse', () => {
101+
it('should type response body correctly', () => {
102+
type UserResponse = TypedResponse<{ id: number, name: string }>
103+
104+
// json() should return the typed body
105+
expectTypeOf<UserResponse['json']>().returns.toEqualTypeOf<Promise<{ id: number, name: string }>>()
106+
107+
// clone() should return TypedResponse with same types
108+
expectTypeOf<UserResponse['clone']>().returns.toEqualTypeOf<UserResponse>()
109+
})
110+
111+
it('should type response headers correctly', () => {
112+
interface CustomHeaders {
113+
'x-rate-limit': string
114+
'x-total-count': string
115+
}
116+
type CustomResponse = TypedResponse<unknown, CustomHeaders>
117+
118+
// headers should be TypedHeaders with custom header map
119+
expectTypeOf<CustomResponse['headers']>().toEqualTypeOf<TypedHeaders<CustomHeaders>>()
120+
121+
// Should be able to get typed headers
122+
const response = {} as CustomResponse
123+
expectTypeOf(response.headers.get).toBeCallableWith('x-rate-limit')
124+
expectTypeOf(response.headers.get).toBeCallableWith('x-total-count')
125+
})
126+
127+
it('should preserve Response interface properties', () => {
128+
type TestResponse = TypedResponse<string>
129+
130+
// Should have all Response properties except the overridden ones
131+
expectTypeOf<TestResponse>().toHaveProperty('status')
132+
expectTypeOf<TestResponse>().toHaveProperty('statusText')
133+
expectTypeOf<TestResponse>().toHaveProperty('ok')
134+
expectTypeOf<TestResponse>().toHaveProperty('redirected')
135+
expectTypeOf<TestResponse>().toHaveProperty('type')
136+
expectTypeOf<TestResponse>().toHaveProperty('url')
137+
expectTypeOf<TestResponse>().toHaveProperty('body')
138+
expectTypeOf<TestResponse>().toHaveProperty('bodyUsed')
139+
140+
// Should have response-specific methods
141+
expectTypeOf<TestResponse>().toHaveProperty('arrayBuffer')
142+
expectTypeOf<TestResponse>().toHaveProperty('blob')
143+
expectTypeOf<TestResponse>().toHaveProperty('formData')
144+
expectTypeOf<TestResponse>().toHaveProperty('text')
145+
146+
// Verify types of properties
147+
expectTypeOf<TestResponse['status']>().toEqualTypeOf<number>()
148+
expectTypeOf<TestResponse['statusText']>().toEqualTypeOf<string>()
149+
expectTypeOf<TestResponse['ok']>().toEqualTypeOf<boolean>()
150+
expectTypeOf<TestResponse['arrayBuffer']>().returns.toEqualTypeOf<Promise<ArrayBuffer>>()
151+
expectTypeOf<TestResponse['text']>().returns.toEqualTypeOf<Promise<string>>()
152+
})
153+
154+
it('should work with different body types', () => {
155+
// String response
156+
type StringResponse = TypedResponse<string>
157+
expectTypeOf<StringResponse['json']>().returns.toEqualTypeOf<Promise<string>>()
158+
159+
// Array response
160+
type ArrayResponse = TypedResponse<Array<{ id: number }>>
161+
expectTypeOf<ArrayResponse['json']>().returns.toEqualTypeOf<Promise<Array<{ id: number }>>>()
162+
163+
// Union type response
164+
type UnionResponse = TypedResponse<{ success: true, data: string } | { success: false, error: string }>
165+
expectTypeOf<UnionResponse['json']>().returns.toEqualTypeOf<Promise<{ success: true, data: string } | { success: false, error: string }>>()
166+
167+
// null response
168+
type NullResponse = TypedResponse<null>
169+
expectTypeOf<NullResponse['json']>().returns.toEqualTypeOf<Promise<null>>()
170+
})
171+
172+
it('should default to unknown body and ResponseHeaderMap headers', () => {
173+
type DefaultResponse = TypedResponse
174+
175+
expectTypeOf<DefaultResponse['json']>().returns.toEqualTypeOf<Promise<unknown>>()
176+
expectTypeOf<DefaultResponse['headers']>().toEqualTypeOf<TypedHeaders<ResponseHeaderMap>>()
177+
})
178+
179+
it('should work with schema-defined response headers', () => {
180+
interface SchemaHeaders {
181+
'content-type': 'application/json'
182+
'x-rate-limit-remaining': string
183+
'x-rate-limit-reset': string
184+
}
185+
type APIResponse = TypedResponse<{ users: Array<{ id: number }> }, SchemaHeaders>
186+
187+
const response = {} as APIResponse
188+
189+
// Should provide typed access to schema headers
190+
expectTypeOf(response.headers.get('content-type')).toEqualTypeOf<'application/json' | null>()
191+
expectTypeOf(response.headers.get('x-rate-limit-remaining')).toEqualTypeOf<string | null>()
192+
193+
// json() should return typed body
194+
expectTypeOf(response.json()).toEqualTypeOf<Promise<{ users: Array<{ id: number }> }>>()
195+
})
196+
})
197+
198+
describe('TypedRequest', () => {
199+
it('should type request body correctly', () => {
200+
type CreateUserRequest = TypedRequest<{ name: string, email: string }>
201+
202+
// json() should return the typed body
203+
expectTypeOf<CreateUserRequest['json']>().returns.toEqualTypeOf<Promise<{ name: string, email: string }>>()
204+
205+
// clone() should return TypedRequest with same header type
206+
expectTypeOf<CreateUserRequest['clone']>().returns.toEqualTypeOf<TypedRequest<ResponseHeaderMap>>()
207+
})
208+
209+
it('should type request headers correctly', () => {
210+
interface CustomHeaders {
211+
'authorization': `Bearer ${string}`
212+
'x-api-key': string
213+
'content-type': 'application/json'
214+
}
215+
type CustomRequest = TypedRequest<unknown, CustomHeaders>
216+
217+
// headers should be TypedHeaders with custom header map
218+
expectTypeOf<CustomRequest['headers']>().toEqualTypeOf<TypedHeaders<CustomHeaders>>()
219+
220+
// Should be able to get typed headers
221+
const request = {} as CustomRequest
222+
expectTypeOf(request.headers.get).toBeCallableWith('authorization')
223+
expectTypeOf(request.headers.get).toBeCallableWith('x-api-key')
224+
expectTypeOf(request.headers.get).toBeCallableWith('content-type')
225+
})
226+
227+
it('should preserve Request interface properties', () => {
228+
type TestRequest = TypedRequest<string>
229+
230+
// Should have all Request properties except the overridden ones
231+
expectTypeOf<TestRequest>().toHaveProperty('method')
232+
expectTypeOf<TestRequest>().toHaveProperty('url')
233+
expectTypeOf<TestRequest>().toHaveProperty('body')
234+
expectTypeOf<TestRequest>().toHaveProperty('bodyUsed')
235+
expectTypeOf<TestRequest>().toHaveProperty('cache')
236+
expectTypeOf<TestRequest>().toHaveProperty('credentials')
237+
expectTypeOf<TestRequest>().toHaveProperty('destination')
238+
expectTypeOf<TestRequest>().toHaveProperty('integrity')
239+
expectTypeOf<TestRequest>().toHaveProperty('keepalive')
240+
expectTypeOf<TestRequest>().toHaveProperty('mode')
241+
expectTypeOf<TestRequest>().toHaveProperty('redirect')
242+
expectTypeOf<TestRequest>().toHaveProperty('referrer')
243+
expectTypeOf<TestRequest>().toHaveProperty('referrerPolicy')
244+
expectTypeOf<TestRequest>().toHaveProperty('signal')
245+
246+
// Should have request-specific methods
247+
expectTypeOf<TestRequest>().toHaveProperty('arrayBuffer')
248+
expectTypeOf<TestRequest>().toHaveProperty('blob')
249+
expectTypeOf<TestRequest>().toHaveProperty('formData')
250+
expectTypeOf<TestRequest>().toHaveProperty('text')
251+
252+
// Verify types of properties
253+
expectTypeOf<TestRequest['method']>().toEqualTypeOf<string>()
254+
expectTypeOf<TestRequest['url']>().toEqualTypeOf<string>()
255+
expectTypeOf<TestRequest['arrayBuffer']>().returns.toEqualTypeOf<Promise<ArrayBuffer>>()
256+
expectTypeOf<TestRequest['text']>().returns.toEqualTypeOf<Promise<string>>()
257+
})
258+
259+
it('should work with different body types', () => {
260+
// JSON request body
261+
type JSONRequest = TypedRequest<{ id: number, data: string }>
262+
expectTypeOf<JSONRequest['json']>().returns.toEqualTypeOf<Promise<{ id: number, data: string }>>()
263+
264+
// Form data (string body)
265+
type FormRequest = TypedRequest<string>
266+
expectTypeOf<FormRequest['json']>().returns.toEqualTypeOf<Promise<string>>()
267+
268+
// No body (never)
269+
type NoBodyRequest = TypedRequest<never>
270+
expectTypeOf<NoBodyRequest['json']>().returns.toEqualTypeOf<Promise<never>>()
271+
272+
// Binary data
273+
type BinaryRequest = TypedRequest<ArrayBuffer>
274+
expectTypeOf<BinaryRequest['json']>().returns.toEqualTypeOf<Promise<ArrayBuffer>>()
275+
})
276+
277+
it('should default to unknown body and ResponseHeaderMap headers', () => {
278+
type DefaultRequest = TypedRequest
279+
280+
expectTypeOf<DefaultRequest['json']>().returns.toEqualTypeOf<Promise<unknown>>()
281+
expectTypeOf<DefaultRequest['headers']>().toEqualTypeOf<TypedHeaders<ResponseHeaderMap>>()
282+
})
283+
284+
it('should work with schema-defined request headers', () => {
285+
interface SchemaHeaders {
286+
'authorization': `Bearer ${string}`
287+
'x-api-version': '1' | '2'
288+
'content-type': 'application/json' | 'application/xml'
289+
}
290+
type APIRequest = TypedRequest<{ action: string }, SchemaHeaders>
291+
292+
const request = {} as APIRequest
293+
294+
// Should provide typed access to schema headers
295+
expectTypeOf(request.headers.get('authorization')).toEqualTypeOf<`Bearer ${string}` | null>()
296+
expectTypeOf(request.headers.get('x-api-version')).toEqualTypeOf<'1' | '2' | null>()
297+
298+
// json() should return typed body
299+
expectTypeOf(request.json()).toEqualTypeOf<Promise<{ action: string }>>()
300+
})
301+
302+
it('should have correct clone method signature', () => {
303+
interface CustomHeaders { 'x-test': string }
304+
type CustomRequest = TypedRequest<{ data: string }, CustomHeaders>
305+
306+
// clone() should return TypedRequest with the Headers type (not Body type)
307+
// This matches the implementation which has clone: () => TypedRequest<Headers>
308+
expectTypeOf<CustomRequest['clone']>().returns.toEqualTypeOf<TypedRequest<CustomHeaders>>()
309+
})
310+
})
311+
312+
describe('Integration with fetchdts inference types', () => {
313+
it('should work with TypedFetchResponseBody and TypedFetchResponseHeaders', () => {
314+
// This test ensures that TypedResponse works well with the inference types
315+
type GetResponse = TypedResponse<
316+
Array<{ id: number, name: string }>,
317+
{ 'x-total-count': string }
318+
>
319+
type PostResponse = TypedResponse<
320+
{ id: number, name: string, email: string },
321+
{ location: string }
322+
>
323+
324+
// Should work correctly
325+
expectTypeOf<GetResponse['json']>().returns.toEqualTypeOf<Promise<Array<{ id: number, name: string }>>>()
326+
expectTypeOf<PostResponse['json']>().returns.toEqualTypeOf<Promise<{ id: number, name: string, email: string }>>()
327+
328+
const getResponse = {} as GetResponse
329+
const postResponse = {} as PostResponse
330+
331+
expectTypeOf(getResponse.headers.get('x-total-count')).toEqualTypeOf<string | null>()
332+
expectTypeOf(postResponse.headers.get('location')).toEqualTypeOf<string | null>()
333+
})
334+
})

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.