Skip to content

Commit 993c00d

Browse files
committed
feat: initial href typing support
1 parent e011687 commit 993c00d

File tree

4 files changed

+239
-122
lines changed

4 files changed

+239
-122
lines changed

packages/next/src/server/lib/router-utils/typegen.ts

Lines changed: 166 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,125 @@ function generateLayoutSlotMap(routesManifest: RouteTypesManifest): string {
141141
return slotMap
142142
}
143143

144+
function generateRouteValidationTypes(
145+
routesManifest: RouteTypesManifest
146+
): string {
147+
const allRoutes = {
148+
...routesManifest.appRoutes,
149+
...routesManifest.pageRoutes,
150+
...routesManifest.layoutRoutes,
151+
}
152+
153+
// Separate static and dynamic routes
154+
const staticRoutes: string[] = []
155+
const dynamicRoutes: string[] = []
156+
157+
for (const [route, routeInfo] of Object.entries(allRoutes)) {
158+
const { groups } = routeInfo
159+
const hasParams = Object.keys(groups).length > 0
160+
161+
if (hasParams) {
162+
dynamicRoutes.push(route)
163+
} else {
164+
staticRoutes.push(route)
165+
}
166+
}
167+
168+
// Sort routes for consistent output
169+
staticRoutes.sort()
170+
dynamicRoutes.sort()
171+
172+
let result = `// Template literal types for route validation
173+
type SearchOrHash = \`?\${string}\` | \`#\${string}\`
174+
type WithProtocol = \`\${string}:\${string}\`
175+
type Suffix = '' | SearchOrHash
176+
177+
type SafeSlug<S extends string> = S extends \`\${string}/\${string}\`
178+
? never
179+
: S extends \`\${string}\${SearchOrHash}\`
180+
? never
181+
: S extends ''
182+
? never
183+
: S
184+
185+
type CatchAllSlug<S extends string> = S extends \`\${string}\${SearchOrHash}\`
186+
? never
187+
: S extends ''
188+
? never
189+
: S
190+
191+
type OptionalCatchAllSlug<S extends string> =
192+
S extends \`\${string}\${SearchOrHash}\` ? never : S
193+
194+
`
195+
196+
// Generate StaticRoutes
197+
if (staticRoutes.length > 0) {
198+
result += `type StaticRoutes = ${staticRoutes.map((route) => `\`${route}\``).join(' | ')}\n\n`
199+
} else {
200+
result += 'type StaticRoutes = never\n\n'
201+
}
202+
203+
// Generate DynamicRoutes template
204+
if (dynamicRoutes.length > 0) {
205+
result += 'type DynamicRoutes<T extends string = string> = '
206+
const dynamicTemplates: string[] = []
207+
208+
for (const route of dynamicRoutes) {
209+
const routeInfo = allRoutes[route]
210+
const { groups } = routeInfo
211+
212+
// Convert route pattern to template literal type
213+
let template = route
214+
for (const [key, group] of Object.entries(groups)) {
215+
if (group.repeat) {
216+
if (group.optional) {
217+
// Optional catch-all: [[...param]]
218+
template = template.replace(
219+
`[[...${key}]]`,
220+
`\${OptionalCatchAllSlug<T>}`
221+
)
222+
} else {
223+
// Catch-all: [...param]
224+
template = template.replace(`[...${key}]`, `\${CatchAllSlug<T>}`)
225+
}
226+
} else {
227+
if (group.optional) {
228+
// Optional param: [[param]]
229+
template = template.replace(`[[${key}]]`, `\${SafeSlug<T>}`)
230+
} else {
231+
// Regular param: [param]
232+
template = template.replace(`[${key}]`, `\${SafeSlug<T>}`)
233+
}
234+
}
235+
}
236+
dynamicTemplates.push(`\`${template}\``)
237+
}
238+
239+
result += `${dynamicTemplates.join(' | ')}\n\n`
240+
} else {
241+
result += 'type DynamicRoutes<T extends string = string> = never\n\n'
242+
}
243+
244+
result += `type ValidRoute<T extends string = string> =
245+
| StaticRoutes
246+
| SearchOrHash
247+
| WithProtocol
248+
| \`\${StaticRoutes}\${SearchOrHash}\`
249+
| (T extends \`\${DynamicRoutes<infer _>}\${Suffix}\` ? T : never)
250+
251+
`
252+
253+
return result
254+
}
255+
144256
export function generateRouteTypesFile(
145257
routesManifest: RouteTypesManifest
146258
): string {
147259
const routeTypes = generateRouteTypes(routesManifest)
148260
const paramTypes = generateParamTypes(routesManifest)
149261
const layoutSlotMap = generateLayoutSlotMap(routesManifest)
262+
const routeValidationTypes = generateRouteValidationTypes(routesManifest)
150263

151264
return `// This file is generated automatically by Next.js
152265
// Do not edit this file manually
@@ -164,6 +277,7 @@ type LayoutChildren<P extends LayoutRoutes> = { children: React.ReactNode } & {
164277
}
165278
166279
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes }
280+
${routeValidationTypes}
167281
168282
declare global {
169283
/**
@@ -208,7 +322,7 @@ type NeedsParams<R extends Routes> =
208322
keyof ParamsOf<R> extends never ? false : true;
209323
210324
/**
211-
* Traditional \`href\` navigation.
325+
* Traditional \`href\` navigation with route validation.
212326
*
213327
* @example
214328
* \`\`\`tsx
@@ -217,8 +331,8 @@ type NeedsParams<R extends Routes> =
217331
* <Link href={{ pathname: '/about', query: { tab: 'contact' } }}>About</Link>
218332
* \`\`\`
219333
*/
220-
export type LinkPropsWithHref = LinkRestProps & {
221-
href: string | UrlObject;
334+
export type LinkPropsWithHref<RouteInferType = string> = LinkRestProps & {
335+
href: ValidRoute<RouteInferType> | UrlObject;
222336
path?: never;
223337
params?: never;
224338
searchParams?: never;
@@ -259,9 +373,9 @@ export type LinkPropsWithPath<T extends Routes> = LinkRestProps &
259373
href?: never;
260374
});
261375
262-
export type LinkProps<RouteType extends Routes = Routes> =
263-
| LinkPropsWithHref
264-
| LinkPropsWithPath<RouteType>;
376+
export type LinkProps<RouteType extends string = string> =
377+
| LinkPropsWithHref<RouteType>
378+
| (RouteType extends Routes ? LinkPropsWithPath<RouteType> : never);
265379
266380
declare module 'next/link' {
267381
/**
@@ -276,18 +390,61 @@ declare module 'next/link' {
276390
*
277391
* @example
278392
* \`\`\`tsx
279-
* // href mode
393+
* // href mode with route validation
280394
* <Link href="/about">About</Link>
395+
* <Link href="/blog/my-post">Blog Post</Link>
281396
*
282-
* // path mode
397+
* // path mode with typed params
283398
* <Link path="/blog/[slug]" params={{ slug: 'my-post' }}>
284399
* Blog
285400
* </Link>
286401
* \`\`\`
287402
*/
288-
export default function Link<RouteType extends Routes = Routes>(
403+
export default function Link<RouteType extends string = string>(
289404
props: LinkProps<RouteType> & { children: React.ReactNode }
290405
): JSX.Element;
291406
}
407+
408+
declare module 'next/navigation' {
409+
export * from 'next/dist/client/components/navigation.js'
410+
411+
import type { NavigateOptions, AppRouterInstance as OriginalAppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js'
412+
interface AppRouterInstance extends OriginalAppRouterInstance {
413+
/**
414+
* Navigate to the provided href.
415+
* Pushes a new history entry.
416+
*/
417+
push<RouteType extends Routes = Routes>(href: ValidRoute<RouteType>, options?: NavigateOptions): void
418+
/**
419+
* Navigate to the provided href.
420+
* Replaces the current history entry.
421+
*/
422+
replace<RouteType extends Routes = Routes>(href: ValidRoute<RouteType>, options?: NavigateOptions): void
423+
/**
424+
* Prefetch the provided href.
425+
*/
426+
prefetch<RouteType extends Routes = Routes>(href: ValidRoute<RouteType>): void
427+
}
428+
429+
export function useRouter(): AppRouterInstance;
430+
}
431+
432+
declare module 'next/form' {
433+
import type { FormProps as OriginalFormProps } from 'next/dist/client/form.js'
434+
435+
type FormRestProps = Omit<OriginalFormProps, 'action'>
436+
437+
export type FormProps<RouteInferType = string> = {
438+
/**
439+
* \`action\` can be either a \`string\` or a function.
440+
* - If \`action\` is a string, it will be interpreted as a path or URL to navigate to when the form is submitted.
441+
* The path will be prefetched when the form becomes visible.
442+
* - If \`action\` is a function, it will be called when the form is submitted. See the [React docs](https://react.dev/reference/react-dom/components/form#props) for more.
443+
*/
444+
action: ValidRoute<RouteInferType> | ((formData: FormData) => void)
445+
} & FormRestProps
446+
447+
export default function Form<RouteType = string>(props: FormProps<RouteType>): JSX.Element
448+
}
292449
`
293450
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import Link from 'next/link'
2+
13
export default function DashboardPage(props: PageProps<'/dashboard'>) {
2-
return <div>Dashboard Home</div>
4+
return (
5+
<div>
6+
<p>Dashboard Home</p>
7+
<Link href="/shop/">Settings</Link>
8+
<Link href="/about">About</Link>
9+
</div>
10+
)
311
}

test/e2e/app-dir/typed-routes/app/dashboard/settings/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import Link from 'next/link'
2+
13
export default function DashboardSettingsPage(
24
props: PageProps<'/dashboard/settings'>
35
) {
46
return (
57
<div>
68
<h4>Dashboard Settings</h4>
9+
<Link href="/dashboard?foo=bar">Dashboard</Link>
710
<p>Main dashboard configuration settings.</p>
811
</div>
912
)

0 commit comments

Comments
 (0)