Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/RouteParamInferTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type Obj = Record<string, string>

export type InterpretOptionalModifier<Param extends string> =
Param extends `${infer Name}?`
? { [T in Name]?: string }
: { [T in Param]: string }
export type InterpretGreedyModifier<Param extends string> =
Param extends `${infer Name}+`
? { [T in Name]: string }
: InterpretOptionalModifier<Param>

export type InterpretModifiers<Param extends string> =
InterpretGreedyModifier<Param>

export type InferEndBasicParam<
T extends string,
Or = Obj
> = T extends `${string}/:${infer Param}` ? InterpretModifiers<Param> : Or

export type InferExtensionParam<
T extends string,
Or = Obj
> = T extends `${infer Prefix}.:${infer Param}`
? InterpretModifiers<Param> & InferEndBasicParam<Prefix, Or>
: InferEndBasicParam<T, Or>

export type InferMiddleBasicParam<
T extends string,
Or = Obj
> = T extends `${string}/:${infer Param}/${infer After}`
? InterpretModifiers<Param> & InferMiddleBasicParam<`/${After}`, Or>
: InferExtensionParam<T, Or>

export type InferParams<T extends string> = InferMiddleBasicParam<
T,
unknown
> extends Obj
? InferMiddleBasicParam<T, unknown>
: Obj
57 changes: 53 additions & 4 deletions src/Router.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import 'isomorphic-fetch'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, expectTypeOf } from 'vitest'
import { buildRequest, createTestRunner, extract } from '../test'
import { Router } from './Router'
import { InferParams, Obj } from './RouteParamInferTypes'

const ERROR_MESSAGE = 'Error Message'

Expand Down Expand Up @@ -507,6 +508,54 @@ describe('Router', () => {
})
})

describe('Router param type inferrence', () => {
it('should be a freeform object by default', () => {
// this will resolve to the originally typed - freeform object
// to make this change less breaking
expectTypeOf<InferParams<'/foo/bar/baz'>>().toEqualTypeOf<Obj>()
})

it('should be able to infer normal params', () => {
expectTypeOf<InferParams<'/todos/:id/:action'>>().toEqualTypeOf<
{ id: string } & { action: string }
>()
})

it('should be able to infer optional params', () => {
expectTypeOf<InferParams<'/todos/:id/:action?'>>().toEqualTypeOf<
{ id: string } & { action?: string }
>()
})

it('should be able to infer extension params', () => {
expectTypeOf<
InferParams<'/files/:folder/:file.:extension'>
>().toEqualTypeOf<
{ folder: string } & { file: string } & { extension: string }
>()

expectTypeOf<InferParams<'/files/manifest.:extension?'>>().toEqualTypeOf<{
extension?: string | undefined
}>()
})

it('should be able to infer a greedy param', () => {
expectTypeOf<InferParams<'/user/:id/:greedy+'>>().toEqualTypeOf<
{ id: string } & { greedy: string }
>()
})

it('should be able to infer all possible options', () => {
expectTypeOf<
InferParams<'/user/:id/:folderId/:file.:extension?'>
>().toEqualTypeOf<
{ id: string } & { folderId: string } & { file: string } & {
extension?: string
}
>()
})
})

describe('MIDDLEWARE', () => {
it('calls any handler until a return', async () => {
const router = Router()
Expand Down Expand Up @@ -632,9 +681,9 @@ describe('ROUTE MATCHING', () => {
{ route: '/test.:x', path: '/test.a', returns: { x: 'a' } },
{ route: '/:x?.y', path: '/test.y', returns: { x: 'test' } },
{ route: '/api(/v1)?/foo', path: '/api/v1/foo' }, // switching support preserved
{ route: '/api(/v1)?/foo', path: '/api/foo' }, // switching support preserved
{ route: '(/api)?/v1/:x', path: '/api/v1/foo', returns: { x: 'foo' } }, // switching support preserved
{ route: '(/api)?/v1/:x', path: '/v1/foo', returns: { x: 'foo' } }, // switching support preserved
{ route: '/api(/v1)?/foo', path: '/api/foo' }, // switching support preserved
{ route: '(/api)?/v1/:x', path: '/api/v1/foo', returns: { x: 'foo' } }, // switching support preserved
{ route: '(/api)?/v1/:x', path: '/v1/foo', returns: { x: 'foo' } }, // switching support preserved
])
})

Expand Down
29 changes: 19 additions & 10 deletions src/Router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { InferParams } from './RouteParamInferTypes'

export type GenericTraps = {
[key: string]: any
}
Expand All @@ -7,13 +9,11 @@ export type RequestLike = {
url: string,
} & GenericTraps

export type IRequestStrict = {
export type IRequestStrict<Params = Record<string, string>> = {
method: string,
url: string,
route: string,
params: {
[key: string]: string,
},
params: Params,
query: {
[key: string]: string | string[] | undefined,
},
Expand All @@ -34,17 +34,26 @@ export type RouteHandler<I = IRequest, A extends any[] = any[]> = {
export type RouteEntry = [string, RegExp, RouteHandler[], string]

// this is the generic "Route", which allows per-route overrides
export type Route = <RequestType = IRequest, Args extends any[] = any[], RT = RouterType>(
path: string,
export type Route = <
Path extends string,
RequestType = Omit<IRequest, 'params'> & {
params: InferParams<Path>
},
Args extends any[] = any[],
RT = RouterType
>(
path: Path,
...handlers: RouteHandler<RequestType, Args>[]
) => RT

// this is an alternative UniveralRoute, accepting generics (from upstream), but without
// per-route overrides
export type UniversalRoute<RequestType = IRequest, Args extends any[] = any[]> = (
path: string,
export type UniversalRoute<Path extends string, RequestType = Omit<IRequest, 'params'> & {
params: InferParams<Path>
}, Args extends any[] = any[]> = (
path: Path,
...handlers: RouteHandler<RequestType, Args>[]
) => RouterType<UniversalRoute<RequestType, Args>, Args>
) => RouterType<UniversalRoute<Path, RequestType, Args>, Args>

// helper function to detect equality in types (used to detect custom Request on router)
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
Expand All @@ -70,7 +79,7 @@ export type RouterType<R = Route, Args extends any[] = any[]> = {
export const Router = <
RequestType = IRequest,
Args extends any[] = any[],
RouteType = Equal<RequestType, IRequest> extends true ? Route : UniversalRoute<RequestType, Args>
RouteType = Equal<RequestType, IRequest> extends true ? Route : UniversalRoute<string, RequestType, Args>
>({ base = '', routes = [] }: RouterOptions = {}): RouterType<RouteType, Args> =>
// @ts-expect-error TypeScript doesn't know that Proxy makes this work
({
Expand Down
Loading