Skip to content
20 changes: 16 additions & 4 deletions packages/docs/guide/advanced/typed-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,39 @@ 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>,
// 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
'named-param-edit'
>
'named-param-edit': RouteRecordInfo<
'named-param-edit',
'/:name/edit',
{ name: string | number }, // raw value
{ name: string }, // normalized value
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
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'>
>()
})
})