Skip to content
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/[uid]'
>
'/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
13 changes: 12 additions & 1 deletion packages/router/src/typed-routes/route-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,26 @@ 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,
> {
name: Name
path: Path
paramsRaw: ParamsRaw
params: Params
// TODO: implement meta with a defineRoute macro
meta: Meta
childrenNames: ChildrenNames
}

export type RouteRecordInfoGeneric = RouteRecordInfo<
string | symbol,
string,
RouteParamsRawGeneric,
RouteParamsGeneric,
RouteMeta,
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 +49,4 @@ export type RouteMap =
/**
* Generic version of the `RouteMap`.
*/
export type RouteMapGeneric = Record<string | symbol, RouteRecordInfo>
export type RouteMapGeneric = Record<string | symbol, RouteRecordInfoGeneric>
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'>
>()
})
})