TypeScript 在 Next.js 16 中的全量实践
Next.js 16 是"TypeScript 原生"框架——配置文件是
.ts、路由参数自动推导、Server Actions 端到端类型安全。但要真正发挥 TypeScript 的威力,你需要理解 strict 模式、Next.js 内置类型、Zod 运行时校验、以及 tRPC 的端到端类型安全。本章从 tsconfig 配置讲起,覆盖所有你在 Next.js 项目中会遇到的类型场景。
1. TypeScript 配置
1.1 tsconfig.json 解析
create-next-app 生成的 tsconfig.json 已经是最佳实践,但值得理解每一项:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}关键配置说明:
| 配置 | 值 | 作用 |
|---|---|---|
strict | true | 开启所有严格检查(必须) |
moduleResolution | "bundler" | 适配 Turbopack/Webpack 的模块解析 |
plugins | [{ "name": "next" }] | 启用 Next.js TypeScript 插件(IDE 增强) |
paths | @/* | 路径别名,避免 ../../../ 地狱 |
.next/types/**/*.ts | include | 自动生成的路由类型声明 |
1.2 strict 模式的价值
strict: true 等价于同时开启以下所有选项:
// 这些都被 strict: true 涵盖
"strictNullChecks": true, // null/undefined 必须显式处理
"strictFunctionTypes": true, // 函数参数类型严格检查
"strictBindCallApply": true, // bind/call/apply 类型检查
"strictPropertyInitialization": true, // 类属性必须初始化
"noImplicitAny": true, // 禁止隐式 any
"noImplicitThis": true, // 禁止隐式 this
"alwaysStrict": true, // 每个文件加 "use strict"永远不要关闭 strict
在 AI SaaS 项目中,关闭 strict 模式就像开车不系安全带——短期省事,长期致命。每一个 as any、每一个未处理的 null,都可能是生产环境的定时炸弹。
1.3 推荐的额外严格配置
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true
}
}2. Next.js 内置类型
2.1 页面组件类型
Next.js 16 中,params 和 searchParams 都是 Promise(异步化,支持 PPR):
// app/chat/[id]/page.tsx
type ChatPageProps = {
params: Promise<{ id: string }>
searchParams: Promise<{ tab?: string; page?: string }>
}
export default async function ChatPage({ params, searchParams }: ChatPageProps) {
const { id } = await params
const { tab = 'messages', page = '1' } = await searchParams
const chat = await getChat(id)
return <ChatDetail chat={chat} activeTab={tab} />
}2.2 Layout 类型
// app/(dashboard)/layout.tsx
type DashboardLayoutProps = {
children: React.ReactNode
modal: React.ReactNode // 并行路由 slot
}
export default function DashboardLayout({ children, modal }: DashboardLayoutProps) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1">{children}</main>
{modal}
</div>
)
}2.3 Route Handler 类型
// app/api/chat/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = Number(searchParams.get('page') ?? '1')
const chats = await db.query.chats.findMany({
limit: 20,
offset: (page - 1) * 20,
})
return NextResponse.json(chats)
}
export async function POST(request: NextRequest) {
const body = await request.json()
// body 是 unknown,需要手动校验
return NextResponse.json({ success: true })
}2.4 Middleware 类型
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token && request.nextUrl.pathname.startsWith('/chat')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/chat/:path*', '/settings/:path*', '/admin/:path*'],
}2.5 Metadata 类型
import type { Metadata, ResolvingMetadata } from 'next'
// 静态 metadata
export const metadata: Metadata = {
title: 'AI 对话',
openGraph: {
title: 'AI 对话',
images: ['/og/chat.png'],
},
}
// 动态 metadata
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
parent: ResolvingMetadata,
): Promise<Metadata> {
const { id } = await params
const chat = await getChat(id)
const previousImages = (await parent).openGraph?.images || []
return {
title: chat.title,
openGraph: {
images: [`/api/og?title=${chat.title}`, ...previousImages],
},
}
}3. Server Actions 类型安全
3.1 基础 Server Action
// app/chat/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const CreateChatSchema = z.object({
title: z.string().min(1, '标题不能为空').max(100),
model: z.enum(['gpt-4o', 'claude-3.5-sonnet', 'gemini-pro']),
})
export async function createChat(formData: FormData) {
const parsed = CreateChatSchema.safeParse({
title: formData.get('title'),
model: formData.get('model'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
const chat = await db.insert(chats).values({
title: parsed.data.title,
model: parsed.data.model,
userId: await getCurrentUserId(),
}).returning()
revalidatePath('/chat')
return { data: chat[0] }
}3.2 next-safe-action:类型安全的 Server Actions
裸 Server Action 的返回值没有类型推导。next-safe-action 解决了这个问题:
pnpm add next-safe-action zod// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth/config'
export const actionClient = createSafeActionClient()
export const authActionClient = createSafeActionClient({
async middleware() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return { userId: session.user.id }
},
})// app/chat/actions.ts
'use server'
import { authActionClient } from '@/lib/safe-action'
import { z } from 'zod'
export const createChat = authActionClient
.schema(z.object({
title: z.string().min(1).max(100),
model: z.enum(['gpt-4o', 'claude-3.5-sonnet']),
}))
.action(async ({ parsedInput, ctx }) => {
// parsedInput 有完整类型推导
// ctx.userId 来自 middleware
const chat = await db.insert(chats).values({
title: parsedInput.title,
model: parsedInput.model,
userId: ctx.userId,
}).returning()
revalidatePath('/chat')
return chat[0]
})// 客户端调用 — 返回值有完整类型
'use client'
import { useAction } from 'next-safe-action/hooks'
import { createChat } from './actions'
export function NewChatButton() {
const { execute, result, isExecuting } = useAction(createChat)
return (
<button
onClick={() => execute({ title: '新对话', model: 'gpt-4o' })}
disabled={isExecuting}
>
{isExecuting ? '创建中...' : '新建对话'}
</button>
// result.data 类型自动推导为 Chat
// result.serverError 类型为 string | undefined
)
}4. Zod:运行时类型校验
4.1 为什么需要 Zod
TypeScript 的类型在编译后会完全消失——它只保护编译时,不保护运行时。对于来自外部的数据(API 请求、表单提交、环境变量),你需要运行时校验:
// ❌ 危险:类型断言不做任何运行时检查
const body = await request.json() as { title: string }
// 如果请求体没有 title 字段,运行时才会崩溃
// ✅ 安全:Zod 在运行时校验
import { z } from 'zod'
const schema = z.object({ title: z.string().min(1) })
const result = schema.safeParse(await request.json())
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
// result.data.title 类型安全且运行时确认4.2 常用 Schema 模式
// types/schemas.ts
import { z } from 'zod'
// 分页参数
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
// 用户注册
export const RegisterSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位').max(100),
name: z.string().min(2, '姓名至少 2 个字符').max(50),
})
// AI 对话消息
export const ChatMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string().min(1).max(10000),
})
// 从 Schema 推导 TypeScript 类型
export type Pagination = z.infer<typeof PaginationSchema>
export type Register = z.infer<typeof RegisterSchema>
export type ChatMessage = z.infer<typeof ChatMessageSchema>5. tRPC 端到端类型安全预览
tRPC 让前后端共享类型零手动定义——后端定义 Router,前端调用时自动获得返回值类型:
// server/trpc/router.ts
import { z } from 'zod'
import { router, protectedProcedure } from './trpc'
export const chatRouter = router({
list: protectedProcedure
.input(z.object({ page: z.number().default(1) }))
.query(async ({ ctx, input }) => {
return db.query.chats.findMany({
where: eq(chats.userId, ctx.userId),
limit: 20,
offset: (input.page - 1) * 20,
})
}),
create: protectedProcedure
.input(z.object({ title: z.string(), model: z.string() }))
.mutation(async ({ ctx, input }) => {
return db.insert(chats).values({
...input,
userId: ctx.userId,
}).returning()
}),
})// 客户端调用 — 零手动类型定义
'use client'
import { trpc } from '@/lib/trpc/client'
export function ChatList() {
const { data: chats } = trpc.chat.list.useQuery({ page: 1 })
// chats 的类型自动推导为数据库查询结果类型
const createChat = trpc.chat.create.useMutation()
// createChat.mutate({ title: '...', model: '...' }) — 参数类型自动校验
}tRPC 的详细集成将在第 16 章深入讲解。
6. 常见类型问题与解决
6.1 类型体操速查表
// 1. 从 API 响应推导类型
type ApiResponse = Awaited<ReturnType<typeof getChats>>
// 2. 组件 Props 提取
type ButtonProps = React.ComponentProps<typeof Button>
// 3. 去除 null/undefined
type NonNullChat = NonNullable<typeof chat>
// 4. 部分字段可选
type PartialChat = Partial<Pick<Chat, 'title' | 'model'>>
// 5. 从数组元素推导类型
type Chat = (typeof chats)[number]6.2 常见错误处理
// ❌ 错误:Type 'string | null' is not assignable to type 'string'
const title = searchParams.get('title') // string | null
db.insert({ title }) // 期望 string
// ✅ 方案1:空值合并
db.insert({ title: title ?? 'Untitled' })
// ✅ 方案2:提前校验
if (!title) throw new Error('Title is required')
db.insert({ title }) // 此时 title 被收窄为 string
// ✅ 方案3:Zod 校验
const { title } = z.object({ title: z.string() }).parse({ title: searchParams.get('title') })6.3 禁止使用的模式
// 🚫 绝对禁止
const data = response as any
const user = body as User // 类型断言不做运行时检查
// 🚫 避免使用
// @ts-ignore
// @ts-expect-error(除非你真的知道在做什么并写了注释)
// ✅ 替代方案
const data = schema.parse(response) // Zod 运行时校验
const user = UserSchema.parse(body) // 类型+运行时双重安全本章小结
- tsconfig.json:
strict: true是底线,配合noUnusedLocals、exactOptionalPropertyTypes更严格 - Next.js 内置类型:
params/searchParams是 Promise、NextRequest/NextResponse、Metadata - Server Actions:用
next-safe-action+ Zod 实现端到端类型安全 - Zod:弥补 TypeScript 运行时校验的缺失,所有外部输入必须经过 Zod 校验
- tRPC:前后端共享类型,零手动定义——后端改了,前端立即报错
- 黄金法则:编译时用 TypeScript,运行时用 Zod,永远不要
as any
下一章,我们将搭建完整的开发调试体系。