Skip to content

Commit 1f49225

Browse files
committed
fix: improve implementation of TypedURLSearchParams
1 parent 33c089f commit 1f49225

File tree

2 files changed

+219
-14
lines changed

2 files changed

+219
-14
lines changed

src/http/url.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,39 @@
1-
export interface TypedURLSearchParams<QueryMap extends Record<string, string> | unknown = Partial<Record<string, string>>> {
2-
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size) */
3-
readonly size: number
1+
export interface TypedURLSearchParams<QueryMap extends Record<string, string> | unknown = Partial<Record<string, string>>> extends Omit<URLSearchParams, 'append' | 'delete' | 'get' | 'getAll' | 'has' | 'set' | 'forEach'> {
42
/**
53
* Appends a specified key/value pair as a new search parameter.
64
*
75
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append)
86
*/
9-
append: <T extends keyof QueryMap>(name: T | string & {}, value: QueryMap[T]) => void
7+
append: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name, value: Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string) => void
108
/**
119
* Deletes the given search parameter, and its associated value, from the list of all search parameters.
1210
*
1311
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete)
1412
*/
15-
delete: <T extends keyof QueryMap>(name: T | string & {}, value?: QueryMap[T]) => void
13+
delete: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name, value?: Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string) => void
1614
/**
1715
* Returns the first value associated to the given search parameter.
1816
*
1917
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get)
2018
*/
21-
get: <T extends keyof QueryMap>(name: T | string & {}) => QueryMap[T] | null
19+
get: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name) => (Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string) | null
2220
/**
2321
* Returns all the values association with a given search parameter.
2422
*
2523
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll)
2624
*/
27-
getAll: <T extends keyof QueryMap>(name: T | string & {}) => Array<QueryMap[T]>
25+
getAll: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name) => Array<Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string>
2826
/**
2927
* Returns a Boolean indicating if such a search parameter exists.
3028
*
3129
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has)
3230
*/
33-
has: <T extends keyof QueryMap>(name: T, value?: QueryMap[T]) => boolean
31+
has: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name, value?: Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string) => boolean
3432
/**
3533
* Sets the value associated to a given search parameter to the given value. If there were several values, delete the others.
3634
*
3735
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set)
3836
*/
39-
set: <T extends keyof QueryMap>(name: T, value: QueryMap[T]) => void
40-
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) */
41-
sort: () => void
42-
/** Returns a string containing a query string suitable for use in a URL. Does not include the question mark. */
43-
toString: () => string
44-
forEach: (callbackfn: (value: string, key: string, parent: URLSearchParams) => void, thisArg?: any) => void
37+
set: <Name extends Extract<keyof QueryMap, string> | string & {}> (name: Name, value: Name extends keyof QueryMap ? QueryMap[Name] extends string ? QueryMap[Name] : string : string) => void
38+
forEach: (callbackfn: (value: QueryMap[keyof QueryMap] | string & {}, key: Extract<keyof QueryMap, string> | string & {}, parent: URLSearchParams) => void, thisArg?: any) => void
4539
}

test/url.test-d.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { TypedURLSearchParams } from '../src/http/url'
2+
import { describe, expectTypeOf, it } from 'vitest'
3+
4+
describe('TypedURLSearchParams', () => {
5+
interface TestQueryMap {
6+
page: '1' | '2'
7+
limit: string
8+
search: string
9+
}
10+
11+
it('should preserve URLSearchParams interface properties', () => {
12+
type TestParams = TypedURLSearchParams<{ test: string }>
13+
14+
// Should have all URLSearchParams properties including size
15+
expectTypeOf<TestParams>().toHaveProperty('size')
16+
expectTypeOf<TestParams['size']>().toEqualTypeOf<Readonly<number>>()
17+
18+
// Should have the typed method overrides
19+
expectTypeOf<TestParams>().toHaveProperty('append')
20+
expectTypeOf<TestParams>().toHaveProperty('delete')
21+
expectTypeOf<TestParams>().toHaveProperty('get')
22+
expectTypeOf<TestParams>().toHaveProperty('getAll')
23+
expectTypeOf<TestParams>().toHaveProperty('has')
24+
expectTypeOf<TestParams>().toHaveProperty('set')
25+
expectTypeOf<TestParams>().toHaveProperty('sort')
26+
expectTypeOf<TestParams>().toHaveProperty('toString')
27+
expectTypeOf<TestParams>().toHaveProperty('forEach')
28+
29+
// Should have all other URLSearchParams properties/methods
30+
expectTypeOf<keyof TestParams>().toEqualTypeOf<keyof URLSearchParams>()
31+
})
32+
33+
it('should type append method correctly', () => {
34+
type AppendMethod = TypedURLSearchParams<TestQueryMap>['append']
35+
36+
expectTypeOf<AppendMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
37+
expectTypeOf<AppendMethod>().parameter(1).toEqualTypeOf<string>()
38+
expectTypeOf<AppendMethod>().returns.toEqualTypeOf<void>()
39+
})
40+
41+
it('should type delete method correctly', () => {
42+
type DeleteMethod = TypedURLSearchParams<TestQueryMap>['delete']
43+
44+
expectTypeOf<DeleteMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
45+
expectTypeOf<DeleteMethod>().parameter(1).toEqualTypeOf<string | undefined>()
46+
expectTypeOf<DeleteMethod>().returns.toEqualTypeOf<void>()
47+
})
48+
49+
it('should type get method correctly', () => {
50+
type GetMethod = TypedURLSearchParams<TestQueryMap>['get']
51+
52+
expectTypeOf<GetMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
53+
expectTypeOf<GetMethod>().returns.toEqualTypeOf<string | null>()
54+
})
55+
56+
it('should type getAll method correctly', () => {
57+
type GetAllMethod = TypedURLSearchParams<TestQueryMap>['getAll']
58+
59+
expectTypeOf<GetAllMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
60+
expectTypeOf<GetAllMethod>().returns.toEqualTypeOf<Array<string>>()
61+
})
62+
63+
it('should type has method correctly', () => {
64+
type HasMethod = TypedURLSearchParams<TestQueryMap>['has']
65+
66+
expectTypeOf<HasMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
67+
expectTypeOf<HasMethod>().parameter(1).toEqualTypeOf<string | undefined>()
68+
expectTypeOf<HasMethod>().returns.toEqualTypeOf<boolean>()
69+
})
70+
71+
it('should type set method correctly', () => {
72+
type SetMethod = TypedURLSearchParams<TestQueryMap>['set']
73+
74+
expectTypeOf<SetMethod>().parameter(0).toEqualTypeOf<keyof TestQueryMap | (string & {})>()
75+
expectTypeOf<SetMethod>().parameter(1).toEqualTypeOf<string>()
76+
expectTypeOf<SetMethod>().returns.toEqualTypeOf<void>()
77+
})
78+
79+
it('should type utility methods correctly', () => {
80+
type SortMethod = TypedURLSearchParams<TestQueryMap>['sort']
81+
type ToStringMethod = TypedURLSearchParams<TestQueryMap>['toString']
82+
type ForEachMethod = TypedURLSearchParams<TestQueryMap>['forEach']
83+
84+
expectTypeOf<SortMethod>().returns.toEqualTypeOf<void>()
85+
expectTypeOf<ToStringMethod>().returns.toEqualTypeOf<string>()
86+
expectTypeOf<ForEachMethod>().parameter(0).toEqualTypeOf<(value: string | string & {}, key: keyof TestQueryMap | string & {}, parent: URLSearchParams) => void>()
87+
expectTypeOf<ForEachMethod>().parameter(1).toEqualTypeOf<any>()
88+
expectTypeOf<ForEachMethod>().returns.toEqualTypeOf<void>()
89+
})
90+
91+
it('should work with default generic type', () => {
92+
type DefaultParams = TypedURLSearchParams
93+
94+
expectTypeOf<DefaultParams['get']>().parameter(0).toEqualTypeOf<string | (string & {})>()
95+
expectTypeOf<DefaultParams['get']>().returns.toEqualTypeOf<string | null>()
96+
})
97+
98+
it('should work with unknown query map', () => {
99+
type UnknownParams = TypedURLSearchParams<unknown>
100+
101+
expectTypeOf<UnknownParams['get']>().parameter(0).toEqualTypeOf<string & {}>()
102+
expectTypeOf<UnknownParams['get']>().returns.toEqualTypeOf<string | null>()
103+
})
104+
105+
it('should work with partial record', () => {
106+
type PartialParams = TypedURLSearchParams<Partial<Record<string, string>>>
107+
108+
expectTypeOf<PartialParams['get']>().parameter(0).toEqualTypeOf<string | (string & {})>()
109+
expectTypeOf<PartialParams['get']>().returns.toEqualTypeOf<string | null>()
110+
})
111+
112+
it('should handle typed query map with specific values', () => {
113+
interface TypedQueryMap {
114+
status: 'active' | 'inactive'
115+
sort: 'asc' | 'desc'
116+
category: string
117+
}
118+
119+
type TypedParams = TypedURLSearchParams<TypedQueryMap>
120+
121+
// get method should return conditional types based on the key
122+
expectTypeOf<TypedParams['get']>().parameter(0).toEqualTypeOf<keyof TypedQueryMap | (string & {})>()
123+
expectTypeOf<TypedParams['get']>().returns.toEqualTypeOf<'active' | 'inactive' | 'asc' | 'desc' | string | null>()
124+
125+
// append/set should accept conditional types based on the key
126+
expectTypeOf<TypedParams['append']>().parameter(1).toEqualTypeOf<'active' | 'inactive' | 'asc' | 'desc' | string>()
127+
expectTypeOf<TypedParams['set']>().parameter(1).toEqualTypeOf<'active' | 'inactive' | 'asc' | 'desc' | string>()
128+
})
129+
130+
it('should preserve URLSearchParams interface methods', () => {
131+
type TestParams = TypedURLSearchParams<{ test: string }>
132+
133+
// Should have all URLSearchParams properties
134+
expectTypeOf<TestParams>().toHaveProperty('size')
135+
expectTypeOf<TestParams>().toHaveProperty('append')
136+
expectTypeOf<TestParams>().toHaveProperty('delete')
137+
expectTypeOf<TestParams>().toHaveProperty('get')
138+
expectTypeOf<TestParams>().toHaveProperty('getAll')
139+
expectTypeOf<TestParams>().toHaveProperty('has')
140+
expectTypeOf<TestParams>().toHaveProperty('set')
141+
expectTypeOf<TestParams>().toHaveProperty('sort')
142+
expectTypeOf<TestParams>().toHaveProperty('toString')
143+
expectTypeOf<TestParams>().toHaveProperty('forEach')
144+
})
145+
146+
it('should handle string & {} pattern for extensibility', () => {
147+
interface QueryMap {
148+
known: string
149+
}
150+
151+
type TestParams = TypedURLSearchParams<QueryMap>
152+
153+
// Should accept both known keys and arbitrary strings
154+
const params = {} as TestParams
155+
156+
expectTypeOf(params.get).toBeCallableWith('known')
157+
expectTypeOf(params.get).toBeCallableWith('unknown-param')
158+
expectTypeOf(params.append).toBeCallableWith('known', 'value')
159+
expectTypeOf(params.append).toBeCallableWith('unknown-param', 'value')
160+
})
161+
162+
it('should handle forEach callback typing', () => {
163+
interface QueryMap {
164+
test: string
165+
another: string
166+
}
167+
168+
type TestParams = TypedURLSearchParams<QueryMap>
169+
const params = {} as TestParams
170+
171+
params.forEach((value, key, parent) => {
172+
expectTypeOf(value).toEqualTypeOf<string>()
173+
expectTypeOf(key).toEqualTypeOf<(string & {}) | keyof QueryMap>()
174+
expectTypeOf(parent).toEqualTypeOf<URLSearchParams>()
175+
})
176+
})
177+
178+
it('should constrain query map to string values', () => {
179+
interface InvalidQueryMap {
180+
id: number // This should still work but be treated as unknown
181+
name: string
182+
}
183+
184+
type InvalidParams = TypedURLSearchParams<InvalidQueryMap>
185+
186+
// The interface should still be usable even with non-string types
187+
expectTypeOf<InvalidParams['get']>().parameter(0).toEqualTypeOf<keyof InvalidQueryMap | (string & {})>()
188+
})
189+
190+
it('should handle string literal types for specific keys', () => {
191+
type TestParams = TypedURLSearchParams<TestQueryMap>
192+
const params = {} as TestParams
193+
194+
// When using the specific 'page' key, should return the literal types
195+
expectTypeOf(params.get('page')).toEqualTypeOf<'1' | '2' | null>()
196+
expectTypeOf(params.getAll('page')).toEqualTypeOf<Array<'1' | '2'>>()
197+
198+
// When setting page, should only accept the literal values or string
199+
expectTypeOf(params.append).toBeCallableWith('page', '1')
200+
expectTypeOf(params.append).toBeCallableWith('page', '2')
201+
expectTypeOf(params.set).toBeCallableWith('page', '1')
202+
expectTypeOf(params.set).toBeCallableWith('page', '2')
203+
204+
// When using other keys with string type, should work with any string
205+
expectTypeOf(params.get('limit')).toEqualTypeOf<string | null>()
206+
expectTypeOf(params.get('search')).toEqualTypeOf<string | null>()
207+
208+
// When using unknown keys, should fallback to string
209+
expectTypeOf(params.get('unknown')).toEqualTypeOf<string | null>()
210+
})
211+
})

0 commit comments

Comments
 (0)