]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat(types): typed MatcherPatternPathCustomParams
authorEduardo San Martin Morote <posva13@gmail.com>
Fri, 8 Aug 2025 12:21:27 +0000 (14:21 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Fri, 8 Aug 2025 12:21:27 +0000 (14:21 +0200)
packages/experiments-playground/src/router/index.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts [new file with mode: 0644]
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts

index 8e11bb1f8509782b5ce80876bfab3f4d4f899cc6..accf3723f03d39998e75be117852f81a7cb858fc 100644 (file)
@@ -142,19 +142,13 @@ const r_profiles_detail = normalizeRouteRecord({
   components: { default: () => import('../pages/profiles/[userId].vue') },
   parent: r_profiles_layout,
   path: new MatcherPatternPathCustomParams(
-    /^\/profiles\/(?<userId>[^/]+)$/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]
   ),
 })
 
index 8fd542fa0d2e7b6b75eaee445a8bdd8b0d34bdc6..fdfd00bab44ce1efcc2ab2582f34a10e4f2be298 100644 (file)
@@ -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 (file)
index 0000000..3fb9d93
--- /dev/null
@@ -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<string>()
+    expectTypeOf(matcher.build({ userId: ['123'] })).toEqualTypeOf<string>()
+    expectTypeOf(matcher.build({ userId: null })).toEqualTypeOf<string>()
+
+    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<string>()
+
+    // @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<string>()
+
+    // @ts-expect-error: must be a number
+    matcher.build({ userId: '2' })
+    // @ts-expect-error: missing userId param
+    matcher.build({})
+  })
+})
index b015241fa0989527e2069e9bf9cfe85c94710816..03497957d5634d2e047aa1024df46c929915d4ac 100644 (file)
@@ -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<string, string> = {}
+
 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<TIn, TOut>
+  parser: Param_GetSet<TIn, TOut>
+}
+
+/**
+ * Helper type to extract the params from the options object.
+ * @internal
+ */
+type ExtractParamTypeFromOptions<TParamsOptions> = {
+  [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<string[] | null, number[] | null>
 
-export class MatcherPatternPathCustomParams implements MatcherPatternPath {
-  private paramsKeys: string[]
+export class MatcherPatternPathCustomParams<
+  TParamsOptions,
+  // TODO: | EmptyObject ?
+  // TParamsOptions extends Record<string, MatcherPatternPathCustomParamOptions>,
+  // TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions<TParamsOptions>
+> implements MatcherPatternPath<ExtractParamTypeFromOptions<TParamsOptions>>
+{
+  private paramsKeys: Array<keyof TParamsOptions>
 
   constructor(
     readonly re: RegExp,
-    readonly params: Record<
-      string,
-      MatcherPatternPathCustomParamOptions<unknown, unknown>
-    >,
+    // 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<string, MatcherPatternPathCustomParamOptions<any, any>>,
     // 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<string | number>
   ) {
-    this.paramsKeys = Object.keys(this.params)
+    this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
   }
 
-  match(path: string): MatcherParamsFormatted {
+  match(path: string): ExtractParamTypeFromOptions<TParamsOptions> {
     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<TParamsOptions>
+    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<TParamsOptions>): 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<NonNullable<Param_GetSet['set']>> = (
+            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<any, any>
+  >,
+>(
+  re: RegExp,
+  params: TParamsOptions,
+  pathParts: Array<string | number>
+): MatcherPatternPathCustomParams<TParamsOptions> {
+  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.