Skip to content

Commit 0ce408d

Browse files
Anoesjposva
andauthored
feat(types): add support for children routes as union (#2475)
Co-authored-by: Eduardo San Martin Morote <[email protected]>
1 parent 9222413 commit 0ce408d

File tree

8 files changed

+171
-45
lines changed

8 files changed

+171
-45
lines changed

packages/docs/guide/advanced/typed-routes.md

+21-8
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,43 @@ export interface RouteNamedMap {
2020
'home',
2121
// this is the path, it will appear in autocompletion
2222
'/',
23-
// these are the raw params. In this case, there are no params allowed
23+
// these are the raw params (what can be passed to router.push() and RouterLink's "to" prop)
24+
// In this case, there are no params allowed
2425
Record<never, never>,
25-
// these are the normalized params
26-
Record<never, never>
26+
// these are the normalized params (what you get from useRoute())
27+
Record<never, never>,
28+
// this is a union of all children route names, in this case, there are none
29+
never
2730
>
28-
// repeat for each route..
31+
// repeat for each route...
2932
// Note you can name them whatever you want
3033
'named-param': RouteRecordInfo<
3134
'named-param',
3235
'/:name',
33-
{ name: string | number }, // raw value
34-
{ name: string } // normalized value
36+
{ name: string | number }, // Allows string or number
37+
{ name: string }, // but always returns a string from the URL
38+
'named-param-edit'
39+
>
40+
'named-param-edit': RouteRecordInfo<
41+
'named-param-edit',
42+
'/:name/edit',
43+
{ name: string | number }, // we also include parent params
44+
{ name: string },
45+
never
3546
>
3647
'article-details': RouteRecordInfo<
3748
'article-details',
3849
'/articles/:id+',
3950
{ id: Array<number | string> },
40-
{ id: string[] }
51+
{ id: string[] },
52+
never
4153
>
4254
'not-found': RouteRecordInfo<
4355
'not-found',
4456
'/:path(.*)',
4557
{ path: string },
46-
{ path: string }
58+
{ path: string },
59+
never
4760
>
4861
}
4962

packages/playground/src/main.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,33 @@ app.use(router)
3232
window.vm = app.mount('#app')
3333

3434
export interface RouteNamedMap {
35-
home: RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>
35+
home: RouteRecordInfo<
36+
'home',
37+
'/',
38+
Record<never, never>,
39+
Record<never, never>,
40+
never
41+
>
3642
'/[name]': RouteRecordInfo<
3743
'/[name]',
3844
'/:name',
3945
{ name: ParamValue<true> },
40-
{ name: ParamValue<false> }
46+
{ name: ParamValue<false> },
47+
'/[name]/edit'
48+
>
49+
'/[name]/edit': RouteRecordInfo<
50+
'/[name]/edit',
51+
'/:name/edit',
52+
{ name: ParamValue<true> },
53+
{ name: ParamValue<false> },
54+
never
4155
>
4256
'/[...path]': RouteRecordInfo<
4357
'/[...path]',
4458
'/:path(.*)',
4559
{ path: ParamValue<true> },
46-
{ path: ParamValue<false> }
60+
{ path: ParamValue<false> },
61+
never
4762
>
4863
}
4964

packages/router/__tests__/routeLocation.test-d.ts

+55-19
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,51 @@ import type {
77
RouteLocationNormalizedTypedList,
88
} from '../src'
99

10-
// TODO: could we move this to an .d.ts file that is only loaded for tests?
10+
// NOTE: A type allows us to make it work only in this test file
1111
// https://github.com/microsoft/TypeScript/issues/15300
1212
type RouteNamedMap = {
1313
home: RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
1414
'/[other]': RouteRecordInfo<
1515
'/[other]',
1616
'/:other',
1717
{ other: ParamValue<true> },
18-
{ other: ParamValue<false> }
18+
{ other: ParamValue<false> },
19+
never
1920
>
20-
'/[name]': RouteRecordInfo<
21-
'/[name]',
22-
'/:name',
23-
{ name: ParamValue<true> },
24-
{ name: ParamValue<false> }
21+
'/groups/[gid]': RouteRecordInfo<
22+
'/groups/[gid]',
23+
'/:gid',
24+
{ gid: ParamValue<true> },
25+
{ gid: ParamValue<false> },
26+
'/groups/[gid]/users' | '/groups/[gid]/users/[uid]'
27+
>
28+
'/groups/[gid]/users': RouteRecordInfo<
29+
'/groups/[gid]/users',
30+
'/:gid/users',
31+
{ gid: ParamValue<true> },
32+
{ gid: ParamValue<false> },
33+
'/groups/[gid]/users/[uid]'
34+
>
35+
'/groups/[gid]/users/[uid]': RouteRecordInfo<
36+
'/groups/[gid]/users/[uid]',
37+
'/:gid/users/:uid',
38+
{ gid: ParamValue<true>; uid: ParamValue<true> },
39+
{ gid: ParamValue<false>; uid: ParamValue<false> },
40+
never
2541
>
2642
'/[...path]': RouteRecordInfo<
2743
'/[...path]',
2844
'/:path(.*)',
2945
{ path: ParamValue<true> },
30-
{ path: ParamValue<false> }
46+
{ path: ParamValue<false> },
47+
never
3148
>
3249
'/deep/nesting/works/[[files]]+': RouteRecordInfo<
3350
'/deep/nesting/works/[[files]]+',
3451
'/deep/nesting/works/:files*',
3552
{ files?: ParamValueZeroOrMore<true> },
36-
{ files?: ParamValueZeroOrMore<false> }
53+
{ files?: ParamValueZeroOrMore<false> },
54+
never
3755
>
3856
}
3957

@@ -48,32 +66,50 @@ describe('Route Location types', () => {
4866
name: Name,
4967
fn: (to: RouteLocationNormalizedTypedList<RouteNamedMap>[Name]) => void
5068
): void
51-
function withRoute<Name extends RouteRecordName>(...args: unknown[]) {}
69+
function withRoute<_Name extends RouteRecordName>(..._args: unknown[]) {}
70+
71+
withRoute('/[other]', to => {
72+
expectTypeOf(to.params).toEqualTypeOf<{ other: string }>()
73+
expectTypeOf(to.params).not.toEqualTypeOf<{ gid: string }>()
74+
expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
75+
})
76+
77+
withRoute('/groups/[gid]', to => {
78+
expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
79+
expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
80+
expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
81+
})
82+
83+
withRoute('/groups/[gid]/users', to => {
84+
expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
85+
expectTypeOf(to.params).not.toEqualTypeOf<{ gid: string; uid: string }>()
86+
expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
87+
})
5288

53-
withRoute('/[name]', to => {
54-
expectTypeOf(to.params).toEqualTypeOf<{ name: string }>()
89+
withRoute('/groups/[gid]/users/[uid]', to => {
90+
expectTypeOf(to.params).toEqualTypeOf<{ gid: string; uid: string }>()
5591
expectTypeOf(to.params).not.toEqualTypeOf<{ notExisting: string }>()
5692
expectTypeOf(to.params).not.toEqualTypeOf<{ other: string }>()
5793
})
5894

59-
withRoute('/[name]' as keyof RouteNamedMap, to => {
95+
withRoute('/groups/[gid]' as keyof RouteNamedMap, to => {
6096
// @ts-expect-error: no all params have this
61-
to.params.name
62-
if (to.name === '/[name]') {
63-
to.params.name
97+
to.params.gid
98+
if (to.name === '/groups/[gid]') {
99+
to.params.gid
64100
// @ts-expect-error: no param other
65101
to.params.other
66102
}
67103
})
68104

69105
withRoute(to => {
70106
// @ts-expect-error: not all params object have a name
71-
to.params.name
107+
to.params.gid
72108
// @ts-expect-error: no route named like that
73109
if (to.name === '') {
74110
}
75-
if (to.name === '/[name]') {
76-
expectTypeOf(to.params).toEqualTypeOf<{ name: string }>()
111+
if (to.name === '/groups/[gid]') {
112+
expectTypeOf(to.params).toEqualTypeOf<{ gid: string }>()
77113
// @ts-expect-error: no param other
78114
to.params.other
79115
}

packages/router/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export type {
113113
RouteLocationAsPathTypedList,
114114

115115
// route records
116+
RouteRecordInfoGeneric,
116117
RouteRecordInfo,
117118
RouteRecordNameGeneric,
118119
RouteRecordName,

packages/router/src/typed-routes/route-map.ts

+18-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { TypesConfig } from '../config'
2-
import type {
3-
RouteMeta,
4-
RouteParamsGeneric,
5-
RouteParamsRawGeneric,
6-
} from '../types'
2+
import type { RouteParamsGeneric, RouteParamsRawGeneric } from '../types'
73
import type { RouteRecord } from '../matcher/types'
84

95
/**
@@ -17,16 +13,30 @@ export interface RouteRecordInfo<
1713
// TODO: could probably be inferred from the Params
1814
ParamsRaw extends RouteParamsRawGeneric = RouteParamsRawGeneric,
1915
Params extends RouteParamsGeneric = RouteParamsGeneric,
20-
Meta extends RouteMeta = RouteMeta,
16+
// NOTE: this is the only type param that feels wrong because its default
17+
// value is the default value to avoid breaking changes but it should be the
18+
// generic version by default instead (string | symbol)
19+
ChildrenNames extends string | symbol = never,
20+
// TODO: implement meta with a defineRoute macro
21+
// Meta extends RouteMeta = RouteMeta,
2122
> {
2223
name: Name
2324
path: Path
2425
paramsRaw: ParamsRaw
2526
params: Params
27+
childrenNames: ChildrenNames
2628
// TODO: implement meta with a defineRoute macro
27-
meta: Meta
29+
// meta: Meta
2830
}
2931

32+
export type RouteRecordInfoGeneric = RouteRecordInfo<
33+
string | symbol,
34+
string,
35+
RouteParamsRawGeneric,
36+
RouteParamsGeneric,
37+
string | symbol
38+
>
39+
3040
/**
3141
* 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.
3242
*/
@@ -38,4 +48,4 @@ export type RouteMap =
3848
/**
3949
* Generic version of the `RouteMap`.
4050
*/
41-
export type RouteMapGeneric = Record<string | symbol, RouteRecordInfo>
51+
export type RouteMapGeneric = Record<string | symbol, RouteRecordInfoGeneric>

packages/router/src/types/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export interface _RouteRecordBase extends PathParserOptions {
257257
* }
258258
* ```
259259
*/
260-
export interface RouteMeta extends Record<string | number | symbol, unknown> {}
260+
export interface RouteMeta extends Record<PropertyKey, unknown> {}
261261

262262
/**
263263
* Route Record defining one single component with the `component` option.

packages/router/src/useApi.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export function useRouter(): Router {
1818
*/
1919
export function useRoute<Name extends keyof RouteMap = keyof RouteMap>(
2020
_name?: Name
21-
): RouteLocationNormalizedLoaded<Name> {
22-
return inject(routeLocationKey)!
21+
) {
22+
return inject(routeLocationKey) as RouteLocationNormalizedLoaded<
23+
Name | RouteMap[Name]['childrenNames']
24+
>
2325
}

packages/router/test-dts/typed-routes.test-d.ts

+53-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
type RouteLocationTyped,
77
createRouter,
88
createWebHistory,
9+
useRoute,
10+
RouteLocationNormalizedLoadedTypedList,
911
} from './index'
1012

1113
// type is needed instead of an interface
@@ -15,23 +17,55 @@ export type RouteMap = {
1517
'/[...path]',
1618
'/:path(.*)',
1719
{ path: ParamValue<true> },
18-
{ path: ParamValue<false> }
20+
{ path: ParamValue<false> },
21+
never
1922
>
2023
'/[a]': RouteRecordInfo<
2124
'/[a]',
2225
'/:a',
2326
{ a: ParamValue<true> },
24-
{ a: ParamValue<false> }
27+
{ a: ParamValue<false> },
28+
never
29+
>
30+
'/a': RouteRecordInfo<
31+
'/a',
32+
'/a',
33+
Record<never, never>,
34+
Record<never, never>,
35+
'/a/b' | '/a/b/c'
36+
>
37+
'/a/b': RouteRecordInfo<
38+
'/a/b',
39+
'/a/b',
40+
Record<never, never>,
41+
Record<never, never>,
42+
'/a/b/c'
43+
>
44+
'/a/b/c': RouteRecordInfo<
45+
'/a/b/c',
46+
'/a/b/c',
47+
Record<never, never>,
48+
Record<never, never>,
49+
never
2550
>
26-
'/a': RouteRecordInfo<'/a', '/a', Record<never, never>, Record<never, never>>
2751
'/[id]+': RouteRecordInfo<
2852
'/[id]+',
2953
'/:id+',
3054
{ id: ParamValueOneOrMore<true> },
31-
{ id: ParamValueOneOrMore<false> }
55+
{ id: ParamValueOneOrMore<false> },
56+
never
3257
>
3358
}
3459

60+
// the type allows for type params to distribute types:
61+
// RouteLocationNormalizedLoadedLoaded<'/[a]' | '/'> will become RouteLocationNormalizedLoadedTyped<RouteMap>['/[a]'] | RouteLocationTypedList<RouteMap>['/']
62+
// it's closer to what the end users uses but with the RouteMap type fixed so it doesn't
63+
// pollute globals
64+
type RouteLocationNormalizedLoaded<
65+
Name extends keyof RouteMap = keyof RouteMap,
66+
> = RouteLocationNormalizedLoadedTypedList<RouteMap>[Name]
67+
// type Test = RouteLocationNormalizedLoaded<'/a' | '/a/b' | '/a/b/c'>
68+
3569
declare module './index' {
3670
interface TypesConfig {
3771
RouteNamedMap: RouteMap
@@ -136,4 +170,19 @@ describe('RouterTyped', () => {
136170
return true
137171
})
138172
})
173+
174+
it('useRoute', () => {
175+
expectTypeOf(useRoute('/[a]')).toEqualTypeOf<
176+
RouteLocationNormalizedLoaded<'/[a]'>
177+
>()
178+
expectTypeOf(useRoute('/a')).toEqualTypeOf<
179+
RouteLocationNormalizedLoaded<'/a' | '/a/b' | '/a/b/c'>
180+
>()
181+
expectTypeOf(useRoute('/a/b')).toEqualTypeOf<
182+
RouteLocationNormalizedLoaded<'/a/b' | '/a/b/c'>
183+
>()
184+
expectTypeOf(useRoute('/a/b/c')).toEqualTypeOf<
185+
RouteLocationNormalizedLoaded<'/a/b/c'>
186+
>()
187+
})
139188
})

0 commit comments

Comments
 (0)