Skip to content

Commit 6243a16

Browse files
committed
feat: type guard file
1 parent e0ba338 commit 6243a16

File tree

4 files changed

+156
-17
lines changed

4 files changed

+156
-17
lines changed

packages/next/src/lib/typescript/writeAppTypeDeclarations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export async function writeAppTypeDeclarations({
5959

6060
if (hasNewTypedRoutes) {
6161
directives.push('/// <reference path="./.next/types/routes.ts" />')
62+
directives.push('/// <reference path="./.next/types/validator.ts" />')
6263
}
6364

6465
// Push the notice in.

packages/next/src/server/lib/router-utils/setup-dev-bundler.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
TurbopackInternalError,
8484
} from '../../../shared/lib/turbopack/utils'
8585
import { getDefineEnv } from '../../../build/define-env'
86-
import { generateRouteTypesFile } from './typegen'
86+
import { generateRouteTypesFile, generateValidatorFile } from './typegen'
8787

8888
export type SetupOpts = {
8989
renderServer: LazyRenderServerInstance
@@ -160,6 +160,11 @@ export interface RouteTypesManifest {
160160
string,
161161
{ path: string } | { slots: string[]; path: string }
162162
>
163+
164+
// For validator.ts - all file paths
165+
appPaths: string[]
166+
pagePaths: string[]
167+
layoutPaths: string[]
163168
}
164169

165170
function createRouteTypesManifest({
@@ -168,12 +173,18 @@ function createRouteTypesManifest({
168173
appPageFilePaths,
169174
appLayoutFilePaths,
170175
layoutSlots,
176+
allAppPagePaths,
177+
allPagesPagePaths,
178+
allAppLayoutPaths,
171179
}: {
172180
dir: string
173181
pagesPageFilePaths: Map<string, string>
174182
appPageFilePaths: Map<string, string>
175183
appLayoutFilePaths: Map<string, string>
176184
layoutSlots: Map<string, Set<string>>
185+
allAppPagePaths: Set<string>
186+
allPagesPagePaths: Set<string>
187+
allAppLayoutPaths: Set<string>
177188
}): RouteTypesManifest {
178189
const appRoutes: Record<string, { path: string }> = {}
179190
const pageRoutes: Record<string, { path: string }> = {}
@@ -212,10 +223,17 @@ function createRouteTypesManifest({
212223
}
213224
}
214225

226+
const appPaths = Array.from(allAppPagePaths).sort()
227+
const pagePaths = Array.from(allPagesPagePaths).sort()
228+
const layoutPaths = Array.from(allAppLayoutPaths).sort()
229+
215230
return {
216231
appRoutes,
217232
pageRoutes,
218233
layoutRoutes,
234+
appPaths,
235+
pagePaths,
236+
layoutPaths,
219237
}
220238
}
221239

@@ -310,12 +328,22 @@ async function startWatcher(
310328
appPageFilePaths: new Map(),
311329
appLayoutFilePaths: new Map(),
312330
layoutSlots: new Map(),
331+
allAppPagePaths: new Set(),
332+
allPagesPagePaths: new Set(),
333+
allAppLayoutPaths: new Set(),
313334
})
314335

315336
await fs.promises.writeFile(
316337
routeTypesFilePath,
317338
generateRouteTypesFile(routeTypesManifest)
318339
)
340+
341+
// Generate validator file
342+
const validatorFilePath = path.join(distDir, 'types', 'validator.ts')
343+
await fs.promises.writeFile(
344+
validatorFilePath,
345+
generateValidatorFile(routeTypesManifest)
346+
)
319347
}
320348

321349
const prerenderManifestPath = path.join(distDir, PRERENDER_MANIFEST)
@@ -420,6 +448,11 @@ async function startWatcher(
420448
const appLayoutFilePaths = new Map<string, string>()
421449
const layoutSlots = new Map<string, Set<string>>()
422450

451+
// Separate collections for ALL file paths (for validator.ts)
452+
const allAppPagePaths = new Set<string>()
453+
const allPagesPagePaths = new Set<string>()
454+
const allAppLayoutPaths = new Set<string>()
455+
423456
let envChange = false
424457
let tsconfigChange = false
425458
let conflictingPageChange = 0
@@ -550,6 +583,19 @@ async function startWatcher(
550583
// Collect all current filenames for the TS plugin to use
551584
devPageFiles.add(fileName)
552585

586+
const relativePath = path.relative(dir, fileName)
587+
588+
// Collect ALL file paths for validator.ts (before any filtering)
589+
if (opts.nextConfig.experimental.newTypedRoutes) {
590+
if (isAppPath && layoutFileRegex.test(fileName)) {
591+
allAppLayoutPaths.add(relativePath)
592+
} else if (isAppPath && validFileMatcher.isAppRouterPage(fileName)) {
593+
allAppPagePaths.add(relativePath)
594+
} else if (!isAppPath && validFileMatcher.isPageFile(fileName)) {
595+
allPagesPagePaths.add(relativePath)
596+
}
597+
}
598+
553599
let pageName = absolutePathToPage(fileName, {
554600
dir: isAppPath ? appDir! : pagesDir!,
555601
extensions: nextConfig.pageExtensions,
@@ -655,6 +701,7 @@ async function startWatcher(
655701
continue
656702
}
657703
} else {
704+
// pages directory
658705
if (useFileSystemPublicRoutes) {
659706
pageFiles.add(pageName)
660707
// always add to nextDataRoutes for now but in future only add
@@ -1067,12 +1114,22 @@ async function startWatcher(
10671114
appPageFilePaths,
10681115
appLayoutFilePaths,
10691116
layoutSlots,
1117+
allAppPagePaths,
1118+
allPagesPagePaths,
1119+
allAppLayoutPaths,
10701120
})
10711121

10721122
await fs.promises.writeFile(
10731123
routeTypesFilePath,
10741124
generateRouteTypesFile(routeTypesManifest)
10751125
)
1126+
1127+
// Generate validator file
1128+
const validatorFilePath = path.join(distDir, 'types', 'validator.ts')
1129+
await fs.promises.writeFile(
1130+
validatorFilePath,
1131+
generateValidatorFile(routeTypesManifest)
1132+
)
10761133
}
10771134

10781135
if (!resolved) {

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

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,100 @@ function deterministicSerialize(obj: object): string {
2323
)
2424
}
2525

26+
export function generateValidatorFile(
27+
routesManifest: RouteTypesManifest
28+
): string {
29+
const { appPaths, pagePaths, layoutPaths } = routesManifest
30+
const basePrefix = '../..'
31+
32+
const generateImports = (paths: string[]) =>
33+
paths
34+
.sort()
35+
.map((filePath) => {
36+
const importPath = `${basePrefix}/${filePath.replace(/\.(tsx?|jsx?)$/, '')}`
37+
return ` ${JSON.stringify(filePath)}: typeof import(${JSON.stringify(importPath)})`
38+
})
39+
.join(',\n')
40+
41+
const appPageImports = generateImports(appPaths)
42+
const pagesPageImports = generateImports(pagePaths)
43+
const allPageImports = [appPageImports, pagesPageImports]
44+
.filter(Boolean)
45+
.join(',\n')
46+
const layoutImports = generateImports(layoutPaths)
47+
48+
const generateValidations = (
49+
paths: string[],
50+
type: 'PageConfig' | 'LayoutConfig'
51+
) =>
52+
paths
53+
.sort()
54+
.map((filePath) => {
55+
const importType = type === 'PageConfig' ? 'Pages' : 'Layouts'
56+
return `// Validate ${filePath}
57+
{
58+
const handler = {} as ${importType}[${JSON.stringify(filePath)}]
59+
handler satisfies ${type}
60+
}`
61+
})
62+
.join('\n\n')
63+
64+
const pageValidations = generateValidations(
65+
[...appPaths, ...pagePaths],
66+
'PageConfig'
67+
)
68+
const layoutValidations = generateValidations(layoutPaths, 'LayoutConfig')
69+
70+
return `// This file is generated automatically by Next.js
71+
// Do not edit this file manually
72+
// This file validates that all pages and layouts export the correct types
73+
74+
type PageConfig = {
75+
default: React.ComponentType<any>
76+
config?: {}
77+
generateStaticParams?: () => Promise<any[]> | any[]
78+
generateMetadata?: (props: any, parent: any) => Promise<any> | any
79+
generateViewport?: (props: any, parent: any) => Promise<any> | any
80+
revalidate?: number | false
81+
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
82+
dynamicParams?: boolean
83+
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
84+
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
85+
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
86+
maxDuration?: number
87+
experimental_ppr?: boolean
88+
}
89+
90+
type LayoutConfig = {
91+
default: React.ComponentType<{ children: React.ReactNode }>
92+
config?: {}
93+
generateStaticParams?: () => Promise<any[]> | any[]
94+
generateMetadata?: (props: any, parent: any) => Promise<any> | any
95+
generateViewport?: (props: any, parent: any) => Promise<any> | any
96+
revalidate?: number | false
97+
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
98+
dynamicParams?: boolean
99+
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
100+
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
101+
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
102+
maxDuration?: number
103+
experimental_ppr?: boolean
104+
}
105+
106+
type Pages = {
107+
${allPageImports}
108+
}
109+
110+
type Layouts = {
111+
${layoutImports}
112+
}
113+
114+
${pageValidations}
115+
116+
${layoutValidations}
117+
`
118+
}
119+
26120
export function generateRouteTypesFile(
27121
routesManifest: RouteTypesManifest
28122
): string {
@@ -44,10 +138,6 @@ type PageRoutes = AppPageRoutes | PagesPageRoutes
44138
type LayoutRoutes = keyof (typeof routesManifest)['layoutRoutes']
45139
type Routes = PageRoutes | LayoutRoutes
46140
47-
// -----------------------------------
48-
// helpers
49-
// -----------------------------------
50-
51141
type Merge<T> = { [K in keyof T]: T[K] }
52142
// extract path params
53143
type Grab<Path extends string> =
@@ -62,10 +152,6 @@ type Grab<Path extends string> =
62152
type ParamMap = { [P in Routes]: Grab<P> }
63153
export type ParamsOf<P extends Routes> = ParamMap[P]
64154
65-
// -----------------------------------
66-
// layout slots
67-
// -----------------------------------
68-
69155
type SlotMap = {
70156
[P in LayoutRoutes]:
71157
(typeof routesManifest)['layoutRoutes'][P] extends { slots: readonly string[] }
@@ -80,17 +166,9 @@ type LayoutChildrenMap = {
80166
81167
type LayoutChildren<P extends LayoutRoutes> = LayoutChildrenMap[P]
82168
83-
// -----------------------------------
84-
// exports
85-
// -----------------------------------
86-
87169
export { routesManifest }
88170
export type { PageRoutes, LayoutRoutes }
89171
90-
// -----------------------------------
91-
// global types
92-
// -----------------------------------
93-
94172
declare global {
95173
/**
96174
* Props for Next.js App Router page components

test/e2e/typed-routes-new/app/blog/[slug]/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// This will throw a type error because it's not one of the allowed values!
2+
// export const dynamic = 'some-random-string'
3+
14
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
25
const params = await props.params
36

0 commit comments

Comments
 (0)