]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat(types): add support for children routes as union (#2475)
authorAnoesj Sadraee <anoesjsadraee@gmail.com>
Fri, 25 Apr 2025 12:30:41 +0000 (14:30 +0200)
committerGitHub <noreply@github.com>
Fri, 25 Apr 2025 12:30:41 +0000 (14:30 +0200)
Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
packages/docs/guide/advanced/typed-routes.md
packages/playground/src/main.ts
packages/router/__tests__/routeLocation.test-d.ts
packages/router/src/index.ts
packages/router/src/typed-routes/route-map.ts
packages/router/src/types/index.ts
packages/router/src/useApi.ts
packages/router/test-dts/typed-routes.test-d.ts

index 5863b94ae6de1ec8c4dec867d4ffb0955a03b00f..f72608494c4304dd3874ff214224ed75109e4835 100644 (file)
@@ -20,30 +20,43 @@ export interface RouteNamedMap {
     'home',
     // this is the path, it will appear in autocompletion
     '/',
-    // these are the raw params. In this case, there are no params allowed
+    // these are the raw params (what can be passed to router.push() and RouterLink's "to" prop)
+    // In this case, there are no params allowed
     Record<never, never>,
-    // these are the normalized params
-    Record<never, never>
+    // these are the normalized params (what you get from useRoute())
+    Record<never, never>,
+    // this is a union of all children route names, in this case, there are none
+    never
   >
-  // repeat for each route..
+  // repeat for each route...
   // Note you can name them whatever you want
   'named-param': RouteRecordInfo<
     'named-param',
     '/:name',
-    { name: string | number }, // raw value
-    { name: string } // normalized value
+    { name: string | number }, // Allows string or number
+    { name: string }, // but always returns a string from the URL
+    'named-param-edit'
+  >
+  'named-param-edit': RouteRecordInfo<
+    'named-param-edit',
+    '/:name/edit',
+    { name: string | number }, // we also include parent params
+    { name: string },
+    never
   >
   'article-details': RouteRecordInfo<
     'article-details',
     '/articles/:id+',
     { id: Array<number | string> },
-    { id: string[] }
+    { id: string[] },
+    never
   >
   'not-found': RouteRecordInfo<
     'not-found',
     '/:path(.*)',
     { path: string },
-    { path: string }
+    { path: string },
+    never
   >
 }
 
index eb0f0e1afdf83891cc8c7dec61b9c96bef0278b1..1df83442278d14475adb11195925598af1eb128e 100644 (file)
@@ -32,18 +32,33 @@ app.use(router)
 window.vm = app.mount('#app')
 
 export interface RouteNamedMap {
-  home: RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>
+  home: RouteRecordInfo<
+    'home',
+    '/',
+    Record<never, never>,
+    Record<never, never>,
+    never
+  >
   '/[name]': RouteRecordInfo<
     '/[name]',
     '/:name',
     { name: ParamValue<true> },
-    { name: ParamValue<false> }
+    { name: ParamValue<false> },
+    '/[name]/edit'
+  >
+  '/[name]/edit': RouteRecordInfo<
+    '/[name]/edit',
+    '/:name/edit',
+    { name: ParamValue<true> },
+    { name: ParamValue<false> },
+    never
   >
   '/[...path]': RouteRecordInfo<
     '/[...path]',
     '/:path(.*)',
     { path: ParamValue<true> },
-    { path: ParamValue<false> }
+    { path: ParamValue<false> },
+    never
   >
 }
 
index f228ba823832cc9217801a7bd0f35b8d71f2204e..423f20a2964faba66a9b77708d9d9741041bf129 100644 (file)
@@ -7,7 +7,7 @@ import type {
   RouteLocationNormalizedTypedList,
 } from '../src'
 
-// TODO: could we move this to an .d.ts file that is only loaded for tests?
+// NOTE: A type allows us to make it work only in this test file
 // https://github.com/microsoft/TypeScript/issues/15300
 type RouteNamedMap = {
   home: RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
@@ -15,25 +15,43 @@ type RouteNamedMap = {
     '/[other]',
     '/:other',
     { other: ParamValue<true> },
-    { other: ParamValue<false> }
+    { other: ParamValue<false> },
+    never
   >
-  '/[name]': RouteRecordInfo<
-    '/[name]',
-    '/:name',
-    { name: ParamValue<true> },
-    { name: ParamValue<false> }
+  '/groups/[gid]': RouteRecordInfo<
+    '/groups/[gid]',
+    '/:gid',
+    { gid: ParamValue<true> },
+    { gid: ParamValue<false> },
+    '/groups/[gid]/users' | '/groups/[gid]/users/[uid]'
+  >
+  '/groups/[gid]/users': RouteRecordInfo<
+    '/groups/[gid]/users',
+    '/:gid/users',
+    { gid: ParamValue<true> },
+    { gid: ParamValue<false> },
+    '/groups/[gid]/users/[uid]'
+  >
+  '/groups/[gid]/users/[uid]': RouteRecordInfo<
+    '/groups/[gid]/users/[uid]',
+    '/:gid/users/:uid',
+    { gid: ParamValue<true>; uid: ParamValue<true> },
+    { gid: ParamValue<false>; uid: ParamValue<false> },
+    never
   >
   '/[...path]': RouteRecordInfo<
     '/[...path]',
     '/:path(.*)',
     { path: ParamValue<true> },
-    { path: ParamValue<false> }
+    { path: ParamValue<false> },
+    never
   >
   '/deep/nesting/works/[[files]]+': RouteRecordInfo<
     '/deep/nesting/works/[[files]]+',
     '/deep/nesting/works/:files*',
     { files?: ParamValueZeroOrMore<true> },
-    { files?: ParamValueZeroOrMore<false> }
+    { files?: ParamValueZeroOrMore<false> },
+    never
   >
 }
 
@@ -48,19 +66,37 @@ describe('Route Location types', () => {
       name: Name,
       fn: (to: RouteLocationNormalizedTypedList<RouteNamedMap>[Name]) => void
     ): void
-    function withRoute<Name extends RouteRecordName>(...args: unknown[]) {}
+    function withRoute<_Name extends RouteRecordName>(..._args: unknown[]) {}
+
+    withRoute('/[other]', to => {
+      expectTypeOf(to.params).toEqualTypeOf<{ other: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ gid: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
+    })
+
+    withRoute('/groups/[gid]', to => {
+      expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
+    })
+
+    withRoute('/groups/[gid]/users', to => {
+      expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ gid: string; uid: string }>()
+      expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
+    })
 
-    withRoute('/[name]', to => {
-      expectTypeOf(to.params).toEqualTypeOf<{ name: string }>()
+    withRoute('/groups/[gid]/users/[uid]', to => {
+      expectTypeOf(to.params).toEqualTypeOf<{ gid: string; uid: string }>()
       expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
       expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
     })
 
-    withRoute('/[name]' as keyof RouteNamedMap, to => {
+    withRoute('/groups/[gid]' as keyof RouteNamedMap, to => {
       // @ts-expect-error: no all params have this
-      to.params.name
-      if (to.name === '/[name]') {
-        to.params.name
+      to.params.gid
+      if (to.name === '/groups/[gid]') {
+        to.params.gid
         // @ts-expect-error: no param other
         to.params.other
       }
@@ -68,12 +104,12 @@ describe('Route Location types', () => {
 
     withRoute(to => {
       // @ts-expect-error: not all params object have a name
-      to.params.name
+      to.params.gid
       // @ts-expect-error: no route named like that
       if (to.name === '') {
       }
-      if (to.name === '/[name]') {
-        expectTypeOf(to.params).toEqualTypeOf<{ name: string }>()
+      if (to.name === '/groups/[gid]') {
+        expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
         // @ts-expect-error: no param other
         to.params.other
       }
index 2a62ad15670c4d4f21d863a81b4cf2cace137422..2b27d83295967543caab06978fdb7a51a7656a26 100644 (file)
@@ -113,6 +113,7 @@ export type {
   RouteLocationAsPathTypedList,
 
   // route records
+  RouteRecordInfoGeneric,
   RouteRecordInfo,
   RouteRecordNameGeneric,
   RouteRecordName,
index b68c72a2fb379effee77c19ce69d1eb478d5e0f2..7a7f0a2ef05ff7a4b90168c09996b25c8009ad65 100644 (file)
@@ -1,9 +1,5 @@
 import type { TypesConfig } from '../config'
-import type {
-  RouteMeta,
-  RouteParamsGeneric,
-  RouteParamsRawGeneric,
-} from '../types'
+import type { RouteParamsGeneric, RouteParamsRawGeneric } from '../types'
 import type { RouteRecord } from '../matcher/types'
 
 /**
@@ -17,16 +13,30 @@ export interface RouteRecordInfo<
   // TODO: could probably be inferred from the Params
   ParamsRaw extends RouteParamsRawGeneric = RouteParamsRawGeneric,
   Params extends RouteParamsGeneric = RouteParamsGeneric,
-  Meta extends RouteMeta = RouteMeta,
+  // NOTE: this is the only type param that feels wrong because its default
+  // value is the default value to avoid breaking changes but it should be the
+  // generic version by default instead (string | symbol)
+  ChildrenNames extends string | symbol = never,
+  // TODO: implement meta with a defineRoute macro
+  // Meta extends RouteMeta = RouteMeta,
 > {
   name: Name
   path: Path
   paramsRaw: ParamsRaw
   params: Params
+  childrenNames: ChildrenNames
   // TODO: implement meta with a defineRoute macro
-  meta: Meta
+  // meta: Meta
 }
 
+export type RouteRecordInfoGeneric = RouteRecordInfo<
+  string | symbol,
+  string,
+  RouteParamsRawGeneric,
+  RouteParamsGeneric,
+  string | symbol
+>
+
 /**
  * Convenience type to get the typed RouteMap or a generic one if not provided. It is extracted from the {@link TypesConfig} if it exists, it becomes {@link RouteMapGeneric} otherwise.
  */
@@ -38,4 +48,4 @@ export type RouteMap =
 /**
  * Generic version of the `RouteMap`.
  */
-export type RouteMapGeneric = Record<string | symbol, RouteRecordInfo>
+export type RouteMapGeneric = Record<string | symbol, RouteRecordInfoGeneric>
index c06643956c85ea4dc2a20b7d7323e3d3e7a7448e..bf8f7fb6c29af4294b14f697ce957300850165e7 100644 (file)
@@ -257,7 +257,7 @@ export interface _RouteRecordBase extends PathParserOptions {
  * }
  * ```
  */
-export interface RouteMeta extends Record<string | number | symbol, unknown> {}
+export interface RouteMeta extends Record<PropertyKey, unknown> {}
 
 /**
  * Route Record defining one single component with the `component` option.
index 988430014e361535a34ebe2f080f4a42cfbf5b45..36930a9622855ba6f78de4e5a2b39bf075b522d1 100644 (file)
@@ -18,6 +18,8 @@ export function useRouter(): Router {
  */
 export function useRoute<Name extends keyof RouteMap = keyof RouteMap>(
   _name?: Name
-): RouteLocationNormalizedLoaded<Name> {
-  return inject(routeLocationKey)!
+) {
+  return inject(routeLocationKey) as RouteLocationNormalizedLoaded<
+    Name | RouteMap[Name]['childrenNames']
+  >
 }
index 7f68498189e4a3b436f22017b41a084ea9791979..c520bdb11129673dda62e7fc63a5bf718822aeff 100644 (file)
@@ -6,6 +6,8 @@ import {
   type RouteLocationTyped,
   createRouter,
   createWebHistory,
+  useRoute,
+  RouteLocationNormalizedLoadedTypedList,
 } from './index'
 
 // type is needed instead of an interface
@@ -15,23 +17,55 @@ export type RouteMap = {
     '/[...path]',
     '/:path(.*)',
     { path: ParamValue<true> },
-    { path: ParamValue<false> }
+    { path: ParamValue<false> },
+    never
   >
   '/[a]': RouteRecordInfo<
     '/[a]',
     '/:a',
     { a: ParamValue<true> },
-    { a: ParamValue<false> }
+    { a: ParamValue<false> },
+    never
+  >
+  '/a': RouteRecordInfo<
+    '/a',
+    '/a',
+    Record<never, never>,
+    Record<never, never>,
+    '/a/b' | '/a/b/c'
+  >
+  '/a/b': RouteRecordInfo<
+    '/a/b',
+    '/a/b',
+    Record<never, never>,
+    Record<never, never>,
+    '/a/b/c'
+  >
+  '/a/b/c': RouteRecordInfo<
+    '/a/b/c',
+    '/a/b/c',
+    Record<never, never>,
+    Record<never, never>,
+    never
   >
-  '/a': RouteRecordInfo<'/a', '/a', Record<never, never>, Record<never, never>>
   '/[id]+': RouteRecordInfo<
     '/[id]+',
     '/:id+',
     { id: ParamValueOneOrMore<true> },
-    { id: ParamValueOneOrMore<false> }
+    { id: ParamValueOneOrMore<false> },
+    never
   >
 }
 
+// the type allows for type params to distribute types:
+// RouteLocationNormalizedLoadedLoaded<'/[a]' | '/'> will become RouteLocationNormalizedLoadedTyped<RouteMap>['/[a]'] | RouteLocationTypedList<RouteMap>['/']
+// it's closer to what the end users uses but with the RouteMap type fixed so it doesn't
+// pollute globals
+type RouteLocationNormalizedLoaded<
+  Name extends keyof RouteMap = keyof RouteMap,
+> = RouteLocationNormalizedLoadedTypedList<RouteMap>[Name]
+// type Test = RouteLocationNormalizedLoaded<'/a' | '/a/b' | '/a/b/c'>
+
 declare module './index' {
   interface TypesConfig {
     RouteNamedMap: RouteMap
@@ -136,4 +170,19 @@ describe('RouterTyped', () => {
       return true
     })
   })
+
+  it('useRoute', () => {
+    expectTypeOf(useRoute('/[a]')).toEqualTypeOf<
+      RouteLocationNormalizedLoaded<'/[a]'>
+    >()
+    expectTypeOf(useRoute('/a')).toEqualTypeOf<
+      RouteLocationNormalizedLoaded<'/a' | '/a/b' | '/a/b/c'>
+    >()
+    expectTypeOf(useRoute('/a/b')).toEqualTypeOf<
+      RouteLocationNormalizedLoaded<'/a/b' | '/a/b/c'>
+    >()
+    expectTypeOf(useRoute('/a/b/c')).toEqualTypeOf<
+      RouteLocationNormalizedLoaded<'/a/b/c'>
+    >()
+  })
 })