Skip to content

Commit 1d6b6ec

Browse files
committed
feat(types): typed MatcherPatternPathCustomParams
1 parent f846fad commit 1d6b6ec

File tree

4 files changed

+194
-41
lines changed

4 files changed

+194
-41
lines changed

packages/experiments-playground/src/router/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,13 @@ const r_profiles_detail = normalizeRouteRecord({
142142
components: { default: () => import('../pages/profiles/[userId].vue') },
143143
parent: r_profiles_layout,
144144
path: new MatcherPatternPathCustomParams(
145-
/^\/profiles\/(?<userId>[^/]+)$/i,
145+
/^\/profiles\/([^/]+)$/i,
146146
{
147147
userId: {
148-
// @ts-expect-error: FIXME: should allow the type
149148
parser: PARAM_INTEGER,
150149
},
151150
},
152-
({ userId }) => {
153-
if (typeof userId !== 'number') {
154-
throw new Error('userId must be a number')
155-
}
156-
return `/profiles/${userId}`
157-
}
151+
['profiles', 0]
158152
),
159153
})
160154

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import {
44
MatcherPatternPathStar,
55
MatcherPatternPathCustomParams,
66
} from './matcher-pattern'
7-
import { pathEncoded } from '../resolver-abstract'
8-
import { invalid } from './errors'
97

108
describe('MatcherPatternPathStatic', () => {
119
describe('match()', () => {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import {
3+
MatcherPatternPathCustomParams,
4+
PARAM_INTEGER,
5+
PATH_PARAM_DEFAULT_PARSER,
6+
PATH_PARAM_SINGLE_DEFAULT,
7+
} from './matcher-pattern'
8+
import { PATH_PARSER_OPTIONS_DEFAULTS } from 'src/matcher/pathParserRanker'
9+
10+
describe('MatcherPatternPathCustomParams', () => {
11+
it('can be generic', () => {
12+
const matcher = new MatcherPatternPathCustomParams(
13+
/^\/users\/([^/]+)$/i,
14+
{ userId: { parser: PATH_PARAM_DEFAULT_PARSER } },
15+
['users', 0]
16+
)
17+
18+
expectTypeOf(matcher.match('/users/123')).toEqualTypeOf<{
19+
userId: string | string[] | null
20+
}>()
21+
22+
expectTypeOf(matcher.build({ userId: '123' })).toEqualTypeOf<string>()
23+
expectTypeOf(matcher.build({ userId: ['123'] })).toEqualTypeOf<string>()
24+
expectTypeOf(matcher.build({ userId: null })).toEqualTypeOf<string>()
25+
26+
matcher.build(
27+
// @ts-expect-error: missing userId param
28+
{}
29+
)
30+
matcher.build(
31+
// @ts-expect-error: wrong param
32+
{ other: '123' }
33+
)
34+
})
35+
36+
it('can be a simple param', () => {
37+
const matcher = new MatcherPatternPathCustomParams(
38+
/^\/users\/([^/]+)\/([^/]+)$/i,
39+
{ userId: { parser: PATH_PARAM_SINGLE_DEFAULT, repeat: true } },
40+
['users', 0]
41+
)
42+
expectTypeOf(matcher.match('/users/123/456')).toEqualTypeOf<{
43+
userId: string
44+
}>()
45+
46+
expectTypeOf(matcher.build({ userId: '123' })).toEqualTypeOf<string>()
47+
48+
// @ts-expect-error: must be a string
49+
matcher.build({ userId: ['123'] })
50+
// @ts-expect-error: missing userId param
51+
matcher.build({})
52+
})
53+
54+
it('can be a custom type', () => {
55+
const matcher = new MatcherPatternPathCustomParams(
56+
/^\/profiles\/([^/]+)$/i,
57+
{
58+
userId: {
59+
parser: PARAM_INTEGER,
60+
// parser: PATH_PARAM_DEFAULT_PARSER,
61+
},
62+
},
63+
['profiles', 0]
64+
)
65+
66+
expectTypeOf(matcher.match('/profiles/2')).toEqualTypeOf<{
67+
userId: number
68+
}>()
69+
70+
expectTypeOf(matcher.build({ userId: 2 })).toEqualTypeOf<string>()
71+
72+
// @ts-expect-error: must be a number
73+
matcher.build({ userId: '2' })
74+
// @ts-expect-error: missing userId param
75+
matcher.build({})
76+
})
77+
})

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts

Lines changed: 115 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,17 @@ interface IdFn {
156156
(v: string[]): string[]
157157
}
158158

159-
const PATH_PARAM_DEFAULT_GET = (value => value ?? null) as IdFn
159+
const PATH_PARAM_DEFAULT_GET = (value: string | string[] | null | undefined) =>
160+
value ?? null
161+
export const PATH_PARAM_SINGLE_DEFAULT: Param_GetSet<string, string> = {}
162+
160163
const PATH_PARAM_DEFAULT_SET = (value: unknown) =>
161164
value && Array.isArray(value) ? value.map(String) : String(value)
162165
// TODO: `(value an null | undefined)` for types
166+
export const PATH_PARAM_DEFAULT_PARSER: Param_GetSet = {
167+
get: PATH_PARAM_DEFAULT_GET,
168+
set: PATH_PARAM_DEFAULT_SET,
169+
}
163170

164171
/**
165172
* NOTE: I tried to make this generic and infer the types from the params but failed. This is what I tried:
@@ -201,7 +208,20 @@ interface MatcherPatternPathCustomParamOptions<
201208
repeat?: boolean
202209
// TODO: not needed because in the regexp, the value is undefined if the group is optional and not given
203210
optional?: boolean
204-
parser?: Param_GetSet<TIn, TOut>
211+
parser: Param_GetSet<TIn, TOut>
212+
}
213+
214+
/**
215+
* Helper type to extract the params from the options object.
216+
* @internal
217+
*/
218+
type ExtractParamTypeFromOptions<TParamsOptions> = {
219+
[K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathCustomParamOptions<
220+
any,
221+
infer TOut
222+
>
223+
? TOut
224+
: never
205225
}
206226

207227
const IS_INTEGER_RE = /^-?[1-9]\d*$/
@@ -238,43 +258,53 @@ export const PARAM_NUMBER_REPEATABLE_OPTIONAL = {
238258
value != null ? PARAM_NUMBER_REPEATABLE.set(value) : null,
239259
} satisfies Param_GetSet<string[] | null, number[] | null>
240260

241-
export class MatcherPatternPathCustomParams implements MatcherPatternPath {
242-
private paramsKeys: string[]
261+
export class MatcherPatternPathCustomParams<
262+
TParamsOptions,
263+
// TODO: | EmptyObject ?
264+
// TParamsOptions extends Record<string, MatcherPatternPathCustomParamOptions>,
265+
// TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions<TParamsOptions>
266+
> implements MatcherPatternPath<ExtractParamTypeFromOptions<TParamsOptions>>
267+
{
268+
private paramsKeys: Array<keyof TParamsOptions>
243269

244270
constructor(
245271
readonly re: RegExp,
246-
readonly params: Record<
247-
string,
248-
MatcherPatternPathCustomParamOptions<unknown, unknown>
249-
>,
272+
// NOTE: this version instead of extends allows the constructor
273+
// to properly infer the types of the params when using `new MatcherPatternPathCustomParams()`
274+
// otherwise, we need to use a factory function: https://github.com/microsoft/TypeScript/issues/40451
275+
readonly params: TParamsOptions &
276+
Record<string, MatcherPatternPathCustomParamOptions<any, any>>,
250277
// A better version could be using all the parts to join them
251278
// .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456
252279
// numbers are indexes of the params in the params object keys
253280
readonly pathParts: Array<string | number>
254281
) {
255-
this.paramsKeys = Object.keys(this.params)
282+
this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
256283
}
257284

258-
match(path: string): MatcherParamsFormatted {
285+
match(path: string): ExtractParamTypeFromOptions<TParamsOptions> {
259286
const match = path.match(this.re)
260287
if (!match) {
261288
throw miss()
262289
}
263290
// NOTE: if we have params, we assume named groups
264-
const params = {} as MatcherParamsFormatted
265-
let i = 1 // index in match array
266-
for (const paramName in this.params) {
267-
const paramOptions = this.params[paramName]
268-
const currentMatch = (match[i] as string | undefined) ?? null
291+
const params = {} as ExtractParamTypeFromOptions<TParamsOptions>
292+
for (var i = 0; i < this.paramsKeys.length; i++) {
293+
var paramName = this.paramsKeys[i]
294+
var paramOptions = this.params[paramName]
295+
var currentMatch = (match[i + 1] as string | undefined) ?? null
269296

270-
const value = paramOptions.repeat
297+
var value = paramOptions.repeat
271298
? (currentMatch?.split('/') || []).map(
272-
// using just decode makes the type inference fail
299+
// using just decode makes the type inference fail
273300
v => decode(v)
274301
)
275302
: decode(currentMatch)
276303

277-
params[paramName] = (paramOptions.parser?.get || (v => v))(value)
304+
params[paramName] = (paramOptions.parser?.get || (v => v))(
305+
value
306+
// NOTE: paramName and paramOptions are not connected from TS point of view
307+
)
278308
}
279309

280310
if (
@@ -289,22 +319,76 @@ export class MatcherPatternPathCustomParams implements MatcherPatternPath {
289319
return params
290320
}
291321

292-
build(params: MatcherParamsFormatted): string {
293-
return this.pathParts.reduce((acc, part) => {
294-
if (typeof part === 'string') {
295-
return acc + '/' + part
296-
}
297-
const paramName = this.paramsKeys[part]
298-
const paramOptions = this.params[paramName]
299-
const value = (paramOptions.parser?.set || (v => v))(params[paramName])
300-
const encodedValue = Array.isArray(value)
301-
? value.map(encodeParam).join('/')
302-
: encodeParam(value)
303-
return encodedValue ? acc + '/' + encodedValue : acc
304-
}, '')
322+
build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
323+
return (
324+
'/' +
325+
this.pathParts
326+
.map(part => {
327+
if (typeof part === 'string') {
328+
return part
329+
}
330+
const paramName = this.paramsKeys[part]
331+
const paramOptions = this.params[paramName]
332+
const value: ReturnType<NonNullable<Param_GetSet['set']>> = (
333+
paramOptions.parser?.set || (v => v)
334+
)(params[paramName])
335+
336+
return Array.isArray(value)
337+
? value.map(encodeParam).join('/')
338+
: encodeParam(value)
339+
})
340+
.filter(Boolean)
341+
.join('/')
342+
)
305343
}
306344
}
307345

346+
const aaa = new MatcherPatternPathCustomParams(
347+
/^\/profiles\/([^/]+)$/i,
348+
{
349+
userId: {
350+
parser: PARAM_INTEGER,
351+
// parser: PATH_PARAM_DEFAULT_PARSER,
352+
},
353+
},
354+
['profiles', 0]
355+
)
356+
// @ts-expect-error: not existing param
357+
aaa.build({ a: '2' })
358+
// @ts-expect-error: must be a number
359+
aaa.build({ userId: '2' })
360+
aaa.build({ userId: 2 })
361+
// @ts-expect-error: not existing param
362+
aaa.match('/profiles/2')?.e
363+
// @ts-expect-error: not existing param
364+
aaa.match('/profiles/2').e
365+
aaa.match('/profiles/2').userId.toFixed(2)
366+
367+
// Factory function for better type inference
368+
export function createMatcherPatternPathCustomParams<
369+
TParamsOptions extends Record<
370+
string,
371+
MatcherPatternPathCustomParamOptions<any, any>
372+
>,
373+
>(
374+
re: RegExp,
375+
params: TParamsOptions,
376+
pathParts: Array<string | number>
377+
): MatcherPatternPathCustomParams<TParamsOptions> {
378+
return new MatcherPatternPathCustomParams(re, params, pathParts)
379+
}
380+
381+
// Now use it like this:
382+
const aab = createMatcherPatternPathCustomParams(
383+
/^\/profiles\/([^/]+)$/i,
384+
{
385+
userId: {
386+
parser: PARAM_INTEGER,
387+
},
388+
},
389+
['profiles', 0]
390+
)
391+
308392
/**
309393
* Matcher for dynamic paths, e.g. `/team/:id/:name`.
310394
* Supports one, one or zero, one or more and zero or more params.

0 commit comments

Comments
 (0)