From 8449e52fe58f8f1a01835b78176e511e1d8e6073 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 13 Jan 2026 00:21:32 +0300 Subject: [PATCH] feat(types): speed up typed routes --- src/build/types.ts | 95 +++++++++--- src/types/fetch/_match.ts | 229 +++++++++++++++++------------ src/types/fetch/fetch.ts | 39 +++-- test/fixture/typed-routes.types.ts | 53 +++++++ 4 files changed, 289 insertions(+), 127 deletions(-) create mode 100644 test/fixture/typed-routes.types.ts diff --git a/src/build/types.ts b/src/build/types.ts index cea33b9168..a97e3dfca8 100644 --- a/src/build/types.ts +++ b/src/build/types.ts @@ -108,26 +108,81 @@ export async function writeTypes(nitro: Nitro) { ]; } - const generateRoutes = () => [ - "// Generated by nitro", - 'import type { Serialize, Simplify } from "nitro/types";', - 'declare module "nitro/types" {', - " type Awaited = T extends PromiseLike ? Awaited : T", - " interface InternalApi {", - ...Object.entries(types.routes).map(([path, methods]) => - [ - ` '${path}': {`, - ...Object.entries(methods).map( - ([method, types]) => ` '${method}': ${types.join(" | ")}` - ), - " }", - ].join("\n") - ), - " }", - "}", - // Makes this a module for augmentation purposes - "export {}", - ]; + const buildRouteTree = () => { + type RouteNode = { + $?: Record; + [segment: string]: RouteNode | Record | undefined; + }; + + const tree: RouteNode = {}; + + for (const [path, methods] of Object.entries(types.routes)) { + const segments = path === "/" ? [] : path.slice(1).split("/"); + let node = tree; + + for (const segment of segments) { + node[segment] ??= {}; + node = node[segment] as RouteNode; + } + + node.$ ??= {}; + + for (const [method, typeEntries] of Object.entries(methods)) { + node.$[method] = [...(node.$[method] ?? []), ...typeEntries]; + } + } + + return tree; + }; + + const renderRouteTree = ( + node: ReturnType, + level: number + ): string[] => { + const lines: string[] = []; + const indent = " ".repeat(level); + const childIndent = " ".repeat(level + 1); + + if (node.$) { + lines.push(`${indent}'$': {`); + for (const [method, types] of Object.entries(node.$)) { + lines.push(`${childIndent}'${method}': ${types.join(" | ")}`); + } + lines.push(`${indent}}`); + } + + for (const [segment, child] of Object.entries(node)) { + if (segment === "$" || !child || typeof child !== "object") { + continue; + } + lines.push(`${indent}'${segment}': {`); + lines.push( + ...renderRouteTree( + child as ReturnType, + level + 1 + ) + ); + lines.push(`${indent}}`); + } + + return lines; + }; + + const generateRoutes = () => { + const routeTree = buildRouteTree(); + return [ + "// Generated by nitro", + 'import type { Serialize, Simplify } from "nitro/types";', + 'declare module "nitro/types" {', + " type Awaited = T extends PromiseLike ? Awaited : T", + " interface InternalApi {", + ...renderRouteTree(routeTree, 2), + " }", + "}", + // Makes this a module for augmentation purposes + "export {}", + ]; + }; const config = [ "// Generated by nitro", diff --git a/src/types/fetch/_match.ts b/src/types/fetch/_match.ts index e352aecbbb..c294a2b116 100644 --- a/src/types/fetch/_match.ts +++ b/src/types/fetch/_match.ts @@ -1,104 +1,139 @@ import type { InternalApi } from "./fetch.ts"; -type MatchResult< - Key extends string, - Exact extends boolean = false, - Score extends any[] = [], - catchAll extends boolean = false, -> = { - [k in Key]: { key: k; exact: Exact; score: Score; catchAll: catchAll }; -}[Key]; - -type Subtract< - Minuend extends any[] = [], - Subtrahend extends any[] = [], -> = Minuend extends [...Subtrahend, ...infer Remainder] ? Remainder : never; - -type TupleIfDiff< - First extends string, - Second extends string, - Tuple extends any[] = [], -> = First extends `${Second}${infer Diff}` - ? Diff extends "" - ? [] - : Tuple - : []; - -type MaxTuple = { - current: T; - result: MaxTuple; -}[[N["length"]] extends [Partial["length"]] ? "current" : "result"]; - -type CalcMatchScore< - Key extends string, - Route extends string, - Score extends any[] = [], - Init extends boolean = false, - FirstKeySegMatcher extends string = Init extends true ? ":Invalid:" : "", -> = `${Key}/` extends `${infer KeySeg}/${infer KeyRest}` - ? KeySeg extends FirstKeySegMatcher // return score if `KeySeg` is empty string (except first pass) - ? Subtract< - [...Score, ...TupleIfDiff], - TupleIfDiff - > - : `${Route}/` extends `${infer RouteSeg}/${infer RouteRest}` - ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` - ? RouteSegWithoutQuery extends KeySeg - ? CalcMatchScore // exact match - : KeySeg extends `:${string}` - ? RouteSegWithoutQuery extends "" - ? never - : CalcMatchScore // param match - : KeySeg extends RouteSegWithoutQuery - ? CalcMatchScore // match by ${string} - : never - : never - : never +type MatchResult = { + node: Node; + path: Path; + exact: Exact; +}; + +type RouteMethods = Node extends { $: infer Methods } ? Methods : never; + +type StripQuery = Route extends `${infer Clean}?${string}` + ? Clean + : Route; + +type StripHash = Route extends `${infer Clean}#${string}` + ? Clean + : Route; + +type StripLeadingSlash = Route extends `/${infer Rest}` + ? Rest + : Route; + +type StripTrailingSlash = Route extends `${infer Rest}/` + ? Rest + : Route; + +type NormalizePath = StripTrailingSlash< + StripLeadingSlash>> +>; + +type SplitPath = + Route extends `${infer Head}/${infer Tail}` + ? [Head, ...SplitPath] + : Route extends "" + ? [] + : [Route]; + +type JoinPath = Prefix extends "" + ? `/${Segment}` + : `${Prefix}/${Segment}`; + +type RootPath = Prefix extends "" ? "/" : Prefix; + +type ParamKey = Extract; +type CatchAllKey = Extract; + +type ExactMatch< + Routes, + Segments extends string[], + Prefix extends string = "", +> = Segments extends [infer Head extends string, ...infer Tail extends string[]] + ? Head extends keyof Routes + ? ExactMatch> + : never + : Routes extends { $: any } + ? MatchResult, true> + : never; + +type LooseMatch< + Routes, + Segments extends string[], + Prefix extends string = "", + Fallback = never, + CatchAll = never, +> = Routes extends object + ? Segments extends [infer Head extends string, ...infer Tail extends string[]] + ? LooseMatchStep + : + | (Routes extends { $: any } + ? MatchResult, false> + : Fallback) + | CatchAll : never; -type _MatchedRoutes< - Route extends string, - MatchedResultUnion extends MatchResult = MatchResult< - keyof InternalApi - >, -> = MatchedResultUnion["key"] extends infer MatchedKeys // spread union type - ? MatchedKeys extends string - ? Route extends MatchedKeys - ? MatchResult // exact match - : MatchedKeys extends `${infer Root}/**${string}` - ? MatchedKeys extends `${string}/**` - ? Route extends `${Root}/${string}` - ? MatchResult - : never // catchAll match - : MatchResult< - MatchedKeys, - false, - CalcMatchScore - > // glob match - : MatchResult< - MatchedKeys, - false, - CalcMatchScore - > // partial match +type LooseMatchStep< + Routes extends object, + Head extends string, + Tail extends string[], + Prefix extends string, + Fallback, + CatchAll, +> = ( + Routes extends { $: any } + ? MatchResult, false> + : Fallback +) extends infer NextFallback + ? ( + CatchAllKey extends never + ? CatchAll + : + | CatchAll + | MatchResult< + Routes[CatchAllKey], + JoinPath & string>, + false + > + ) extends infer NextCatchAll + ? Head extends keyof Routes + ? LooseMatch< + Routes[Head], + Tail, + JoinPath, + NextFallback, + NextCatchAll + > + : ParamKey extends never + ? CatchAllKey extends never + ? NextFallback | NextCatchAll + : + | MatchResult< + Routes[CatchAllKey], + JoinPath & string>, + false + > + | NextCatchAll + | NextFallback + : LooseMatch< + Routes[ParamKey], + Tail, + JoinPath & string>, + NextFallback, + NextCatchAll + > : never : never; -export type MatchedRoutes< - Route extends string, - MatchedKeysResult extends MatchResult = MatchResult< - keyof InternalApi - >, - Matches extends MatchResult = _MatchedRoutes< - Route, - MatchedKeysResult - >, -> = Route extends "/" - ? keyof InternalApi // root middleware - : Extract extends never - ? // @ts-ignore - | Extract< - Exclude, - { score: MaxTuple } - >["key"] - | Extract["key"] // partial, glob and catchAll matches - : Extract["key"]; // exact matches +type MatchRoute = + ExactMatch>> extends infer Exact + ? [Exact] extends [never] + ? LooseMatch>> + : Exact + : never; + +export type MatchedRouteMethods = + MatchRoute extends MatchResult + ? RouteMethods + : never; + +export type MatchedRoutes = MatchedRouteMethods; diff --git a/src/types/fetch/fetch.ts b/src/types/fetch/fetch.ts index 26d832a568..5cff102d3a 100644 --- a/src/types/fetch/fetch.ts +++ b/src/types/fetch/fetch.ts @@ -1,6 +1,6 @@ import type { HTTPMethod } from "h3"; import type { FetchOptions, FetchRequest, FetchResponse } from "ofetch"; -import type { MatchedRoutes } from "./_match.ts"; +import type { MatchedRouteMethods } from "./_match.ts"; // An interface to extend in a local project export interface InternalApi {} @@ -8,16 +8,38 @@ export interface InternalApi {} // TODO: upgrade to uppercase for h3 v2 types and web consistency type RouterMethod = Lowercase; +type StripMethodsKey = T extends "$" ? never : T; + +type JoinPath = Prefix extends "" + ? `/${Segment}` + : `${Prefix}/${Segment}`; + +type RootPath = Prefix extends "" ? "/" : Prefix; + +type RoutePaths = Routes extends object + ? + | (Routes extends { $: any } ? RootPath : never) + | { + [K in keyof Routes]: K extends string + ? StripMethodsKey extends never + ? never + : RoutePaths> + : never; + }[keyof Routes] + : never; + +type InternalApiPaths = RoutePaths; + export type NitroFetchRequest = - | Exclude + | Exclude | Exclude | (string & {}); export type MiddlewareOf< Route extends string, Method extends RouterMethod | "default", -> = Method extends keyof InternalApi[MatchedRoutes] - ? InternalApi[MatchedRoutes][Method] +> = Method extends keyof MatchedRouteMethods + ? MatchedRouteMethods[Method] : never; export type TypedInternalResponse< @@ -40,13 +62,10 @@ export type TypedInternalResponse< // Defaults to all methods if there aren't any methods available or if there is a catch-all route. export type AvailableRouterMethod = R extends string - ? keyof InternalApi[MatchedRoutes] extends undefined + ? keyof MatchedRouteMethods extends undefined ? RouterMethod - : Extract< - keyof InternalApi[MatchedRoutes], - "default" - > extends undefined - ? Extract]> + : Extract, "default"> extends undefined + ? Extract> : RouterMethod : RouterMethod; diff --git a/test/fixture/typed-routes.types.ts b/test/fixture/typed-routes.types.ts new file mode 100644 index 0000000000..488b74e3f7 --- /dev/null +++ b/test/fixture/typed-routes.types.ts @@ -0,0 +1,53 @@ +import type { AvailableRouterMethod, TypedInternalResponse } from "nitro/types"; + +type UsersGet = { users: string[] }; +type UsersPost = { created: true }; +type UserById = { id: string }; +type FilesGet = { path: string }; + +declare module "nitro/types" { + interface InternalApi { + api: { + users: { + $: { + get: UsersGet; + post: UsersPost; + }; + ":id": { + $: { + get: UserById; + }; + }; + }; + files: { + "**": { + $: { + get: FilesGet; + }; + }; + }; + }; + } +} + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Expect = T; + +export type TypedUsersGet = Expect< + Equal, UsersGet> +>; +export type TypedUsersPost = Expect< + Equal, UsersPost> +>; +export type TypedUserId = Expect< + Equal, UserById> +>; +export type TypedFiles = Expect< + Equal, FilesGet> +>; +export type TypedUsersMethods = Expect< + Equal, "get" | "post"> +>;