From 1d6b6ec919debb733501027bc83a47cd019aa9e5 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 8 Aug 2025 14:21:27 +0200 Subject: [PATCH] feat(types): typed MatcherPatternPathCustomParams --- .../src/router/index.ts | 10 +- .../matchers/matcher-pattern.spec.ts | 2 - .../matchers/matcher-pattern.test-d.ts | 77 +++++++++ .../matchers/matcher-pattern.ts | 146 ++++++++++++++---- 4 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts diff --git a/packages/experiments-playground/src/router/index.ts b/packages/experiments-playground/src/router/index.ts index 8e11bb1f..accf3723 100644 --- a/packages/experiments-playground/src/router/index.ts +++ b/packages/experiments-playground/src/router/index.ts @@ -142,19 +142,13 @@ const r_profiles_detail = normalizeRouteRecord({ components: { default: () => import('../pages/profiles/[userId].vue') }, parent: r_profiles_layout, path: new MatcherPatternPathCustomParams( - /^\/profiles\/(?[^/]+)$/i, + /^\/profiles\/([^/]+)$/i, { userId: { - // @ts-expect-error: FIXME: should allow the type parser: PARAM_INTEGER, }, }, - ({ userId }) => { - if (typeof userId !== 'number') { - throw new Error('userId must be a number') - } - return `/profiles/${userId}` - } + ['profiles', 0] ), }) diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts index 8fd542fa..fdfd00ba 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts @@ -4,8 +4,6 @@ import { MatcherPatternPathStar, MatcherPatternPathCustomParams, } from './matcher-pattern' -import { pathEncoded } from '../resolver-abstract' -import { invalid } from './errors' describe('MatcherPatternPathStatic', () => { describe('match()', () => { diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts new file mode 100644 index 00000000..3fb9d93d --- /dev/null +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts @@ -0,0 +1,77 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { + MatcherPatternPathCustomParams, + PARAM_INTEGER, + PATH_PARAM_DEFAULT_PARSER, + PATH_PARAM_SINGLE_DEFAULT, +} from './matcher-pattern' +import { PATH_PARSER_OPTIONS_DEFAULTS } from 'src/matcher/pathParserRanker' + +describe('MatcherPatternPathCustomParams', () => { + it('can be generic', () => { + const matcher = new MatcherPatternPathCustomParams( + /^\/users\/([^/]+)$/i, + { userId: { parser: PATH_PARAM_DEFAULT_PARSER } }, + ['users', 0] + ) + + expectTypeOf(matcher.match('/users/123')).toEqualTypeOf<{ + userId: string | string[] | null + }>() + + expectTypeOf(matcher.build({ userId: '123' })).toEqualTypeOf() + expectTypeOf(matcher.build({ userId: ['123'] })).toEqualTypeOf() + expectTypeOf(matcher.build({ userId: null })).toEqualTypeOf() + + matcher.build( + // @ts-expect-error: missing userId param + {} + ) + matcher.build( + // @ts-expect-error: wrong param + { other: '123' } + ) + }) + + it('can be a simple param', () => { + const matcher = new MatcherPatternPathCustomParams( + /^\/users\/([^/]+)\/([^/]+)$/i, + { userId: { parser: PATH_PARAM_SINGLE_DEFAULT, repeat: true } }, + ['users', 0] + ) + expectTypeOf(matcher.match('/users/123/456')).toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf(matcher.build({ userId: '123' })).toEqualTypeOf() + + // @ts-expect-error: must be a string + matcher.build({ userId: ['123'] }) + // @ts-expect-error: missing userId param + matcher.build({}) + }) + + it('can be a custom type', () => { + const matcher = new MatcherPatternPathCustomParams( + /^\/profiles\/([^/]+)$/i, + { + userId: { + parser: PARAM_INTEGER, + // parser: PATH_PARAM_DEFAULT_PARSER, + }, + }, + ['profiles', 0] + ) + + expectTypeOf(matcher.match('/profiles/2')).toEqualTypeOf<{ + userId: number + }>() + + expectTypeOf(matcher.build({ userId: 2 })).toEqualTypeOf() + + // @ts-expect-error: must be a number + matcher.build({ userId: '2' }) + // @ts-expect-error: missing userId param + matcher.build({}) + }) +}) diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts index b015241f..03497957 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts @@ -156,10 +156,17 @@ interface IdFn { (v: string[]): string[] } -const PATH_PARAM_DEFAULT_GET = (value => value ?? null) as IdFn +const PATH_PARAM_DEFAULT_GET = (value: string | string[] | null | undefined) => + value ?? null +export const PATH_PARAM_SINGLE_DEFAULT: Param_GetSet = {} + const PATH_PARAM_DEFAULT_SET = (value: unknown) => value && Array.isArray(value) ? value.map(String) : String(value) // TODO: `(value an null | undefined)` for types +export const PATH_PARAM_DEFAULT_PARSER: Param_GetSet = { + get: PATH_PARAM_DEFAULT_GET, + set: PATH_PARAM_DEFAULT_SET, +} /** * 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< repeat?: boolean // TODO: not needed because in the regexp, the value is undefined if the group is optional and not given optional?: boolean - parser?: Param_GetSet + parser: Param_GetSet +} + +/** + * Helper type to extract the params from the options object. + * @internal + */ +type ExtractParamTypeFromOptions = { + [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathCustomParamOptions< + any, + infer TOut + > + ? TOut + : never } const IS_INTEGER_RE = /^-?[1-9]\d*$/ @@ -238,43 +258,53 @@ export const PARAM_NUMBER_REPEATABLE_OPTIONAL = { value != null ? PARAM_NUMBER_REPEATABLE.set(value) : null, } satisfies Param_GetSet -export class MatcherPatternPathCustomParams implements MatcherPatternPath { - private paramsKeys: string[] +export class MatcherPatternPathCustomParams< + TParamsOptions, + // TODO: | EmptyObject ? + // TParamsOptions extends Record, + // TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions +> implements MatcherPatternPath> +{ + private paramsKeys: Array constructor( readonly re: RegExp, - readonly params: Record< - string, - MatcherPatternPathCustomParamOptions - >, + // NOTE: this version instead of extends allows the constructor + // to properly infer the types of the params when using `new MatcherPatternPathCustomParams()` + // otherwise, we need to use a factory function: https://github.com/microsoft/TypeScript/issues/40451 + readonly params: TParamsOptions & + Record>, // A better version could be using all the parts to join them // .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456 // numbers are indexes of the params in the params object keys readonly pathParts: Array ) { - this.paramsKeys = Object.keys(this.params) + this.paramsKeys = Object.keys(this.params) as Array } - match(path: string): MatcherParamsFormatted { + match(path: string): ExtractParamTypeFromOptions { const match = path.match(this.re) if (!match) { throw miss() } // NOTE: if we have params, we assume named groups - const params = {} as MatcherParamsFormatted - let i = 1 // index in match array - for (const paramName in this.params) { - const paramOptions = this.params[paramName] - const currentMatch = (match[i] as string | undefined) ?? null + const params = {} as ExtractParamTypeFromOptions + for (var i = 0; i < this.paramsKeys.length; i++) { + var paramName = this.paramsKeys[i] + var paramOptions = this.params[paramName] + var currentMatch = (match[i + 1] as string | undefined) ?? null - const value = paramOptions.repeat + var value = paramOptions.repeat ? (currentMatch?.split('/') || []).map( - // using just decode makes the type inference fail + // using just decode makes the type inference fail v => decode(v) ) : decode(currentMatch) - params[paramName] = (paramOptions.parser?.get || (v => v))(value) + params[paramName] = (paramOptions.parser?.get || (v => v))( + value + // NOTE: paramName and paramOptions are not connected from TS point of view + ) } if ( @@ -289,22 +319,76 @@ export class MatcherPatternPathCustomParams implements MatcherPatternPath { return params } - build(params: MatcherParamsFormatted): string { - return this.pathParts.reduce((acc, part) => { - if (typeof part === 'string') { - return acc + '/' + part - } - const paramName = this.paramsKeys[part] - const paramOptions = this.params[paramName] - const value = (paramOptions.parser?.set || (v => v))(params[paramName]) - const encodedValue = Array.isArray(value) - ? value.map(encodeParam).join('/') - : encodeParam(value) - return encodedValue ? acc + '/' + encodedValue : acc - }, '') + build(params: ExtractParamTypeFromOptions): string { + return ( + '/' + + this.pathParts + .map(part => { + if (typeof part === 'string') { + return part + } + const paramName = this.paramsKeys[part] + const paramOptions = this.params[paramName] + const value: ReturnType> = ( + paramOptions.parser?.set || (v => v) + )(params[paramName]) + + return Array.isArray(value) + ? value.map(encodeParam).join('/') + : encodeParam(value) + }) + .filter(Boolean) + .join('/') + ) } } +const aaa = new MatcherPatternPathCustomParams( + /^\/profiles\/([^/]+)$/i, + { + userId: { + parser: PARAM_INTEGER, + // parser: PATH_PARAM_DEFAULT_PARSER, + }, + }, + ['profiles', 0] +) +// @ts-expect-error: not existing param +aaa.build({ a: '2' }) +// @ts-expect-error: must be a number +aaa.build({ userId: '2' }) +aaa.build({ userId: 2 }) +// @ts-expect-error: not existing param +aaa.match('/profiles/2')?.e +// @ts-expect-error: not existing param +aaa.match('/profiles/2').e +aaa.match('/profiles/2').userId.toFixed(2) + +// Factory function for better type inference +export function createMatcherPatternPathCustomParams< + TParamsOptions extends Record< + string, + MatcherPatternPathCustomParamOptions + >, +>( + re: RegExp, + params: TParamsOptions, + pathParts: Array +): MatcherPatternPathCustomParams { + return new MatcherPatternPathCustomParams(re, params, pathParts) +} + +// Now use it like this: +const aab = createMatcherPatternPathCustomParams( + /^\/profiles\/([^/]+)$/i, + { + userId: { + parser: PARAM_INTEGER, + }, + }, + ['profiles', 0] +) + /** * Matcher for dynamic paths, e.g. `/team/:id/:name`. * Supports one, one or zero, one or more and zero or more params. -- 2.47.3