Skip to content

feat: make typed useRoute(currentRouteName) return children route types as well #2475

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 25, 2025
30 changes: 24 additions & 6 deletions packages/docs/guide/advanced/typed-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ It's possible to configure the router to have a _map_ of typed routes. While thi
Here is an example of how to manually configure typed routes:

```ts
// import the `RouteRecordInfo` type from vue-router to type your routes
import type { RouteRecordInfo } from 'vue-router'
// import the `RouteRecordInfo` and `RouteMeta` type from vue-router to type your routes
import type { RouteRecordInfo, RouteMeta } from 'vue-router'

// Define an interface of routes
export interface RouteNamedMap {
Expand All @@ -23,27 +23,45 @@ export interface RouteNamedMap {
// these are the raw params. In this case, there are no params allowed
Record<never, never>,
// these are the normalized params
Record<never, never>
Record<never, never>,
// these are the `meta` fields
RouteMeta,
// this is a union of all children route names
never
>
// 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 }, // normalized value
RouteMeta,
'named-param-edit'
>
'named-param-edit': RouteRecordInfo<
'named-param-edit',
'/:name/edit',
{ name: string | number }, // raw value
{ name: string }, // normalized value
RouteMeta,
never
>
'article-details': RouteRecordInfo<
'article-details',
'/articles/:id+',
{ id: Array<number | string> },
{ id: string[] }
{ id: string[] },
RouteMeta,
never
>
'not-found': RouteRecordInfo<
'not-found',
'/:path(.*)',
{ path: string },
{ path: string }
{ path: string },
RouteMeta,
never
>
}

Expand Down
32 changes: 28 additions & 4 deletions packages/playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import type { ComponentPublicInstance } from 'vue'
import { router, routerHistory } from './router'
import { globalState } from './store'
import App from './App.vue'
import { useRoute, type ParamValue, type RouteRecordInfo } from 'vue-router'
import {
useRoute,
type ParamValue,
type RouteRecordInfo,
type RouteMeta,
} from 'vue-router'

declare global {
interface Window {
Expand Down Expand Up @@ -32,18 +37,37 @@ 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>,
RouteMeta,
never
>
'/[name]': RouteRecordInfo<
'/[name]',
'/:name',
{ name: ParamValue<true> },
{ name: ParamValue<false> }
{ name: ParamValue<false> },
RouteMeta,
'/[name]/edit'
>
'/[name]/edit': RouteRecordInfo<
'/[name]/edit',
'/:name/edit',
{ name: ParamValue<true> },
{ name: ParamValue<false> },
RouteMeta,
never
>
'/[...path]': RouteRecordInfo<
'/[...path]',
'/:path(.*)',
{ path: ParamValue<true> },
{ path: ParamValue<false> }
{ path: ParamValue<false> },
RouteMeta,
never
>
}

Expand Down
81 changes: 62 additions & 19 deletions packages/router/__tests__/routeLocation.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,61 @@ import type {
ParamValue,
ParamValueZeroOrMore,
RouteRecordInfo,
RouteMeta,
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>>
'/[other]': RouteRecordInfo<
'/[other]',
'/:other',
{ other: ParamValue<true> },
{ other: ParamValue<false> }
{ other: ParamValue<false> },
RouteMeta,
never
>
'/[name]': RouteRecordInfo<
'/[name]',
'/:name',
{ name: ParamValue<true> },
{ name: ParamValue<false> }
'/groups/[gid]': RouteRecordInfo<
'/groups/[gid]',
'/:gid',
{ gid: ParamValue<true> },
{ gid: ParamValue<false> },
RouteMeta,
'/groups/[gid]/users'
>
'/groups/[gid]/users': RouteRecordInfo<
'/groups/[gid]/users',
'/:gid/users',
{ gid: ParamValue<true> },
{ gid: ParamValue<false> },
RouteMeta,
'/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> },
RouteMeta,
never
>
'/[...path]': RouteRecordInfo<
'/[...path]',
'/:path(.*)',
{ path: ParamValue<true> },
{ path: ParamValue<false> }
{ path: ParamValue<false> },
RouteMeta,
never
>
'/deep/nesting/works/[[files]]+': RouteRecordInfo<
'/deep/nesting/works/[[files]]+',
'/deep/nesting/works/:files*',
{ files?: ParamValueZeroOrMore<true> },
{ files?: ParamValueZeroOrMore<false> }
{ files?: ParamValueZeroOrMore<false> },
RouteMeta,
never
>
}

Expand All @@ -48,32 +73,50 @@ 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
}
})

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
}
Expand Down
2 changes: 2 additions & 0 deletions packages/router/src/typed-routes/route-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ export interface RouteRecordInfo<
ParamsRaw extends RouteParamsRawGeneric = RouteParamsRawGeneric,
Params extends RouteParamsGeneric = RouteParamsGeneric,
Meta extends RouteMeta = RouteMeta,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this type param because it was never used and I figured it's safer to add it back later if needed than leaving an unused type param that can eventually be used. I marked this as a fix because the leftover type param should have never been released in the first place

ChildrenNames extends string | symbol | never = never | string | symbol,
> {
name: Name
path: Path
paramsRaw: ParamsRaw
params: Params
// TODO: implement meta with a defineRoute macro
meta: Meta
childrenNames: ChildrenNames
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/router/src/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
>
}
64 changes: 60 additions & 4 deletions packages/router/test-dts/typed-routes.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
type ParamValue,
type ParamValueOneOrMore,
type RouteLocationTyped,
type RouteMeta,
createRouter,
createWebHistory,
useRoute,
RouteLocationNormalizedLoadedTypedList,
} from './index'

// type is needed instead of an interface
Expand All @@ -15,23 +18,61 @@ export type RouteMap = {
'/[...path]',
'/:path(.*)',
{ path: ParamValue<true> },
{ path: ParamValue<false> }
{ path: ParamValue<false> },
RouteMeta,
never
>
'/[a]': RouteRecordInfo<
'/[a]',
'/:a',
{ a: ParamValue<true> },
{ a: ParamValue<false> }
{ a: ParamValue<false> },
RouteMeta,
never
>
'/a': RouteRecordInfo<
'/a',
'/a',
Record<never, never>,
Record<never, never>,
RouteMeta,
'/a/b' | '/a/b/c'
>
'/a/b': RouteRecordInfo<
'/a/b',
'/a/b',
Record<never, never>,
Record<never, never>,
RouteMeta,
'/a/b/c'
>
'/a/b/c': RouteRecordInfo<
'/a/b/c',
'/a/b/c',
Record<never, never>,
Record<never, never>,
RouteMeta,
never
>
'/a': RouteRecordInfo<'/a', '/a', Record<never, never>, Record<never, never>>
'/[id]+': RouteRecordInfo<
'/[id]+',
'/:id+',
{ id: ParamValueOneOrMore<true> },
{ id: ParamValueOneOrMore<false> }
{ id: ParamValueOneOrMore<false> },
RouteMeta,
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
Expand Down Expand Up @@ -136,4 +177,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'>
>()
})
})