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
29 changes: 21 additions & 8 deletions packages/docs/guide/advanced/typed-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
}

Expand Down
21 changes: 18 additions & 3 deletions packages/playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
}

Expand Down
74 changes: 55 additions & 19 deletions packages/router/__tests__/routeLocation.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,51 @@ 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>>
'/[other]': RouteRecordInfo<
'/[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
>
}

Expand All @@ -48,32 +66,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
1 change: 1 addition & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type {
RouteLocationAsPathTypedList,

// route records
RouteRecordInfoGeneric,
RouteRecordInfo,
RouteRecordNameGeneric,
RouteRecordName,
Expand Down
26 changes: 18 additions & 8 deletions packages/router/src/typed-routes/route-map.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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,
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

// 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.
*/
Expand All @@ -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>
2 changes: 1 addition & 1 deletion packages/router/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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']
>
}
57 changes: 53 additions & 4 deletions packages/router/test-dts/typed-routes.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type RouteLocationTyped,
createRouter,
createWebHistory,
useRoute,
RouteLocationNormalizedLoadedTypedList,
} from './index'

// type is needed instead of an interface
Expand All @@ -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
Expand Down Expand Up @@ -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'>
>()
})
})