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"]
}

关键配置说明:

配置作用
stricttrue开启所有严格检查(必须)
moduleResolution"bundler"适配 Turbopack/Webpack 的模块解析
plugins[{ "name": "next" }]启用 Next.js TypeScript 插件(IDE 增强)
paths@/*路径别名,避免 ../../../ 地狱
.next/types/**/*.tsinclude自动生成的路由类型声明

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 中,paramssearchParams 都是 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.jsonstrict: true 是底线,配合 noUnusedLocalsexactOptionalPropertyTypes 更严格
  • Next.js 内置类型params / searchParams 是 Promise、NextRequest / NextResponseMetadata
  • Server Actions:用 next-safe-action + Zod 实现端到端类型安全
  • Zod:弥补 TypeScript 运行时校验的缺失,所有外部输入必须经过 Zod 校验
  • tRPC:前后端共享类型,零手动定义——后端改了,前端立即报错
  • 黄金法则:编译时用 TypeScript,运行时用 Zod,永远不要 as any

下一章,我们将搭建完整的开发调试体系。