From: Anoesj Sadraee Date: Fri, 25 Apr 2025 12:30:41 +0000 (+0200) Subject: feat(types): add support for children routes as union (#2475) X-Git-Tag: v4.5.1~6 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0ce408dadbb45a4790e2ccee311a032cc7c6d957;p=thirdparty%2Fvuejs%2Frouter.git feat(types): add support for children routes as union (#2475) Co-authored-by: Eduardo San Martin Morote --- diff --git a/packages/docs/guide/advanced/typed-routes.md b/packages/docs/guide/advanced/typed-routes.md index 5863b94a..f7260849 100644 --- a/packages/docs/guide/advanced/typed-routes.md +++ b/packages/docs/guide/advanced/typed-routes.md @@ -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, - // these are the normalized params - Record + // these are the normalized params (what you get from useRoute()) + Record, + // 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 }, - { id: string[] } + { id: string[] }, + never > 'not-found': RouteRecordInfo< 'not-found', '/:path(.*)', { path: string }, - { path: string } + { path: string }, + never > } diff --git a/packages/playground/src/main.ts b/packages/playground/src/main.ts index eb0f0e1a..1df83442 100644 --- a/packages/playground/src/main.ts +++ b/packages/playground/src/main.ts @@ -32,18 +32,33 @@ app.use(router) window.vm = app.mount('#app') export interface RouteNamedMap { - home: RouteRecordInfo<'home', '/', Record, Record> + home: RouteRecordInfo< + 'home', + '/', + Record, + Record, + never + > '/[name]': RouteRecordInfo< '/[name]', '/:name', { name: ParamValue }, - { name: ParamValue } + { name: ParamValue }, + '/[name]/edit' + > + '/[name]/edit': RouteRecordInfo< + '/[name]/edit', + '/:name/edit', + { name: ParamValue }, + { name: ParamValue }, + never > '/[...path]': RouteRecordInfo< '/[...path]', '/:path(.*)', { path: ParamValue }, - { path: ParamValue } + { path: ParamValue }, + never > } diff --git a/packages/router/__tests__/routeLocation.test-d.ts b/packages/router/__tests__/routeLocation.test-d.ts index f228ba82..423f20a2 100644 --- a/packages/router/__tests__/routeLocation.test-d.ts +++ b/packages/router/__tests__/routeLocation.test-d.ts @@ -7,7 +7,7 @@ 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, Record> @@ -15,25 +15,43 @@ type RouteNamedMap = { '/[other]', '/:other', { other: ParamValue }, - { other: ParamValue } + { other: ParamValue }, + never > - '/[name]': RouteRecordInfo< - '/[name]', - '/:name', - { name: ParamValue }, - { name: ParamValue } + '/groups/[gid]': RouteRecordInfo< + '/groups/[gid]', + '/:gid', + { gid: ParamValue }, + { gid: ParamValue }, + '/groups/[gid]/users' | '/groups/[gid]/users/[uid]' + > + '/groups/[gid]/users': RouteRecordInfo< + '/groups/[gid]/users', + '/:gid/users', + { gid: ParamValue }, + { gid: ParamValue }, + '/groups/[gid]/users/[uid]' + > + '/groups/[gid]/users/[uid]': RouteRecordInfo< + '/groups/[gid]/users/[uid]', + '/:gid/users/:uid', + { gid: ParamValue; uid: ParamValue }, + { gid: ParamValue; uid: ParamValue }, + never > '/[...path]': RouteRecordInfo< '/[...path]', '/:path(.*)', { path: ParamValue }, - { path: ParamValue } + { path: ParamValue }, + never > '/deep/nesting/works/[[files]]+': RouteRecordInfo< '/deep/nesting/works/[[files]]+', '/deep/nesting/works/:files*', { files?: ParamValueZeroOrMore }, - { files?: ParamValueZeroOrMore } + { files?: ParamValueZeroOrMore }, + never > } @@ -48,19 +66,37 @@ describe('Route Location types', () => { name: Name, fn: (to: RouteLocationNormalizedTypedList[Name]) => void ): void - function withRoute(...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 } @@ -68,12 +104,12 @@ describe('Route Location types', () => { 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 } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 2a62ad15..2b27d832 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -113,6 +113,7 @@ export type { RouteLocationAsPathTypedList, // route records + RouteRecordInfoGeneric, RouteRecordInfo, RouteRecordNameGeneric, RouteRecordName, diff --git a/packages/router/src/typed-routes/route-map.ts b/packages/router/src/typed-routes/route-map.ts index b68c72a2..7a7f0a2e 100644 --- a/packages/router/src/typed-routes/route-map.ts +++ b/packages/router/src/typed-routes/route-map.ts @@ -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' /** @@ -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, + // 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. */ @@ -38,4 +48,4 @@ export type RouteMap = /** * Generic version of the `RouteMap`. */ -export type RouteMapGeneric = Record +export type RouteMapGeneric = Record diff --git a/packages/router/src/types/index.ts b/packages/router/src/types/index.ts index c0664395..bf8f7fb6 100644 --- a/packages/router/src/types/index.ts +++ b/packages/router/src/types/index.ts @@ -257,7 +257,7 @@ export interface _RouteRecordBase extends PathParserOptions { * } * ``` */ -export interface RouteMeta extends Record {} +export interface RouteMeta extends Record {} /** * Route Record defining one single component with the `component` option. diff --git a/packages/router/src/useApi.ts b/packages/router/src/useApi.ts index 98843001..36930a96 100644 --- a/packages/router/src/useApi.ts +++ b/packages/router/src/useApi.ts @@ -18,6 +18,8 @@ export function useRouter(): Router { */ export function useRoute( _name?: Name -): RouteLocationNormalizedLoaded { - return inject(routeLocationKey)! +) { + return inject(routeLocationKey) as RouteLocationNormalizedLoaded< + Name | RouteMap[Name]['childrenNames'] + > } diff --git a/packages/router/test-dts/typed-routes.test-d.ts b/packages/router/test-dts/typed-routes.test-d.ts index 7f684981..c520bdb1 100644 --- a/packages/router/test-dts/typed-routes.test-d.ts +++ b/packages/router/test-dts/typed-routes.test-d.ts @@ -6,6 +6,8 @@ import { type RouteLocationTyped, createRouter, createWebHistory, + useRoute, + RouteLocationNormalizedLoadedTypedList, } from './index' // type is needed instead of an interface @@ -15,23 +17,55 @@ export type RouteMap = { '/[...path]', '/:path(.*)', { path: ParamValue }, - { path: ParamValue } + { path: ParamValue }, + never > '/[a]': RouteRecordInfo< '/[a]', '/:a', { a: ParamValue }, - { a: ParamValue } + { a: ParamValue }, + never + > + '/a': RouteRecordInfo< + '/a', + '/a', + Record, + Record, + '/a/b' | '/a/b/c' + > + '/a/b': RouteRecordInfo< + '/a/b', + '/a/b', + Record, + Record, + '/a/b/c' + > + '/a/b/c': RouteRecordInfo< + '/a/b/c', + '/a/b/c', + Record, + Record, + never > - '/a': RouteRecordInfo<'/a', '/a', Record, Record> '/[id]+': RouteRecordInfo< '/[id]+', '/:id+', { id: ParamValueOneOrMore }, - { id: ParamValueOneOrMore } + { id: ParamValueOneOrMore }, + never > } +// the type allows for type params to distribute types: +// RouteLocationNormalizedLoadedLoaded<'/[a]' | '/'> will become RouteLocationNormalizedLoadedTyped['/[a]'] | RouteLocationTypedList['/'] +// 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[Name] +// type Test = RouteLocationNormalizedLoaded<'/a' | '/a/b' | '/a/b/c'> + declare module './index' { interface TypesConfig { RouteNamedMap: RouteMap @@ -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'> + >() + }) })