Next.js 与 TypeScript

要点

TypeScript 在开发时检查代码关系,不会自动验证服务器返回的 JSON。把任意数据断言成 User 只是告诉编译器信任你。

类型安全覆盖到哪里

  • 说明 tsconfig.json 里 strict 与 paths 各自影响哪一类检查
  • 为页面 props、路由参数和 API 响应分别写出类型来源,并标出哪一部分来自运行时
  • 把一段外部 JSON 从 unknown 收窄到业务类型,而不是直接断言
  • 在 CI 里跑一次 tsc --strict --noEmit 作为全量类型检查

容易卡住的地方

  • 用 as User 断言外部数据,让编译器误以为数据已经可信
  • 服务端密钥相关的类型被带进客户端 bundle
  • 页面组件为了拿 props 写成客户端组件,失去服务端渲染的收益

概述

本章说明 TypeScript 在 Next.js 项目里能做什么、不能做什么。类型检查覆盖的是代码之间的关系,而服务端返回的 JSON、URL 参数和第三方接口响应仍然属于运行时数据,需要在边界上单独校验。

TypeScript项目配置

自动配置(推荐)

# 创建项目时自动配置TypeScript
npx create-next-app@latest my-ts-app --typescript --eslint --app

1. 或者手动回答配置问题
npx create-next-app@latest my-ts-app
1. Would you like to use TypeScript? … Yes

手动配置

# 安装TypeScript依赖
npm install --save-dev typescript @types/react @types/node
npm install --save-dev @types/react-dom

1. 创建TypeScript配置文件
npx tsc --init

tsconfig.json详解

{
  "compilerOptions": {
    // 目标版本
    "target": "ES2017",

    // 模块系统
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,

    // 严格检查
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,

    // Next.js插件
    "plugins": [
      {
        "name": "next"
      }
    ],

    // 路径别名
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/types/*": ["./src/types/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

类型定义和声明

Next.js环境类型

// next-env.d.ts (自动生成)
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

全局类型声明

// src/types/global.d.ts
import { NextApiRequest, NextApiResponse } from 'next'

// 扩展全局类型
declare global {
  interface Window {
    // 浏览器全局变量
    gtag?: (...args: any[]) => void
    dataLayer?: any[]
  }
}

// API类型定义
export type ApiHandler<T = any> = (
  req: NextApiRequest,
  res: NextApiResponse<T>
) => Promise<void> | void

// 用户类型
export interface User {
  id: string
  name: string
  email: string
  avatar?: string
  role: 'admin' | 'user' | 'guest'
  createdAt: Date
  updatedAt: Date
}

// API响应类型
export interface ApiResponse<T = any> {
  success: boolean
  data?: T
  error?: string
  message?: string
}

类型安全的组件开发

基础组件类型

// src/types/components.ts
import { ReactNode, ButtonHTMLAttributes, InputHTMLAttributes } from 'react'

// 组件Props基础类型
export interface BaseComponentProps {
  className?: string
  children?: ReactNode
}

// 按钮组件类型
export interface ButtonProps extends BaseComponentProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
  onClick?: () => void
}

// 输入框组件类型
export interface InputProps extends BaseComponentProps {
  type?: string
  placeholder?: string
  value?: string
  defaultValue?: string
  error?: string
  required?: boolean
  disabled?: boolean
  onChange?: (value: string) => void
}

类型安全的组件实现

// src/components/Button.tsx
import { ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { ButtonProps } from '@/types/components'

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({
    children,
    className,
    variant = 'primary',
    size = 'md',
    disabled = false,
    loading = false,
    onClick,
    ...props
  }, ref) => {
    const baseClasses = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2'

    const variantClasses = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
      secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
      danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
      ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500'
    }

    const sizeClasses = {
      sm: 'px-3 py-1.5 text-sm',
      md: 'px-4 py-2 text-base',
      lg: 'px-6 py-3 text-lg'
    }

    return (
      <button
        ref={ref}
        className={cn(
          baseClasses,
          variantClasses[variant],
          sizeClasses[size],
          (disabled || loading) && 'opacity-50 cursor-not-allowed',
          className
        )}
        disabled={disabled || loading}
        onClick={onClick}
        {...props}
      >
        {loading ? (
          <span className="inline-flex items-center">
            <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
            </svg>
            加载中...
          </span>
        ) : children}
      </button>
    )
  }
)

Button.displayName = 'Button'

export default Button

泛型组件

// src/components/List.tsx
import { ReactNode } from 'react'

interface ListProps<T> {
  items: T[]
  renderItem: (item: T, index: number) => ReactNode
  keyExtractor: (item: T) => string
  emptyMessage?: string
  className?: string
}

export function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = '暂无数据',
  className = ''
}: ListProps<T>) {
  if (items.length === 0) {
    return (
      <div className={`text-center py-8 text-gray-500 ${className}`}>
        {emptyMessage}
      </div>
    )
  }

  return (
    <div className={className}>
      {items.map((item, index) => (
        <div key={keyExtractor(item)}>
          {renderItem(item, index)}
        </div>
      ))}
    </div>
  )
}

// 使用示例
interface User {
  id: string
  name: string
  email: string
}

// 在组件中使用
function UserList({ users }: { users: User[] }) {
  return (
    <List
      items={users}
      keyExtractor={(user) => user.id}
      renderItem={(user) => (
        <div className="p-4 border-b">
          <h3 className="font-semibold">{user.name}</h3>
          <p className="text-gray-600">{user.email}</p>
        </div>
      )}
      className="divide-y"
    />
  )
}

页面和路由的类型安全

App Router类型定义

// src/types/pages.ts
import { Metadata } from 'next'

// 页面 Props 类型(Next.js 15+:params / searchParams 是 Promise,需 await)
export interface PageProps {
  params: Promise<Record<string, string>>
  searchParams?: Promise<Record<string, string | string[] | undefined>>
}

// 布局 Props 类型
export interface LayoutProps {
  children: ReactNode
  params: Promise<Record<string, string>>
}

// 元数据生成函数类型
export type MetadataGenerator = (props: PageProps) => Promise<Metadata>

类型安全的页面组件

// src/app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { PageProps } from '@/types/pages'

interface Post {
  id: string
  title: string
  content: string
  author: string
  createdAt: string
}

// 模拟数据获取
async function getPost(id: string): Promise<Post | null> {
  // 这里应该是实际的数据库查询
  const posts: Record<string, Post> = {
    '1': {
      id: '1',
      title: 'TypeScript入门指南',
      content: 'TypeScript是JavaScript的超集...',
      author: '张三',
      createdAt: '2025-01-01'
    }
  }

  return posts[id] || null
}

// 类型安全的页面组件(params 是 Promise<{ id: string }>)
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params

  const post = await getPost(id)

  if (!post) {
    notFound()
  }

  return (
    <div className="max-w-4xl mx-auto py-8">
      <article className="prose prose-lg">
        <h1>{post.title}</h1>
        <div className="text-gray-600 mb-4">
          作者:{post.author} | 发布时间:{post.createdAt}
        </div>
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
    </div>
  )
}

// 生成元数据
export async function generateMetadata(
  { params }: { params: Promise<{ id: string }> }
): Promise<Metadata> {
  const { id } = await params
  const post = await getPost(id)

  return {
    title: post?.title || '文章不存在',
    description: post?.content?.substring(0, 160) || '文章详情页面',
  }
}

API路由的类型安全

API 类型定义(App Router 的 route.ts)

本章项目使用 App Router,接口写在 src/app/api/*/route.ts,处理函数接收标准 Web 的 Request 并返回 Response。Pages Router 的 NextApiRequest/NextApiResponse 只用于 pages/api 目录,不要混用。

// src/types/api.ts

// HTTP 方法类型
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

// API 响应类型
export interface ApiResponse<T = unknown> {
  success: boolean
  data?: T
  error?: string
  message?: string
  timestamp: string
}

// App Router 路由处理器类型(route.ts 导出的函数)
export type RouteHandler = (
  request: Request,
  context: { params: Promise<Record<string, string>> }
) => Promise<Response>

类型安全的 API 实现(App Router route.ts)

// src/app/api/users/route.ts
import { NextResponse } from 'next/server'
import { createUserSchema } from '@/lib/validation'
import type { ApiResponse, User } from '@/types/api'

// 模拟用户数据
const users: User[] = [
  { id: '1', name: '张三', email: 'zhangsan@example.com', avatar: '', role: 'user', createdAt: new Date(), updatedAt: new Date() },
  { id: '2', name: '李四', email: 'lisi@example.com', avatar: '', role: 'admin', createdAt: new Date(), updatedAt: new Date() }
]

function ok<T>(data: T, init?: number) {
  const body: ApiResponse<T> = { success: true, data, timestamp: new Date().toISOString() }
  return NextResponse.json(body, { status: init ?? 200 })
}

function fail(error: string, status: number) {
  const body: ApiResponse<never> = { success: false, error, timestamp: new Date().toISOString() }
  return NextResponse.json(body, { status })
}

// GET /api/users
export async function GET() {
  return ok(users)
}

// POST /api/users
export async function POST(request: Request) {
  // 不用 as 断言请求体:用 zod 在边界校验并收窄到 CreateUserInput
  const parsed = createUserSchema.safeParse(await request.json())
  if (!parsed.success) {
    return fail(parsed.error.issues.map((e) => e.message).join(', '), 400)
  }

  const { name, email, role } = parsed.data

  // 检查邮箱是否已存在
  if (users.some((user) => user.email === email)) {
    return fail('邮箱已存在', 409)
  }

  const newUser: User = {
    id: String(users.length + 1),
    name,
    email,
    role,
    createdAt: new Date(),
    updatedAt: new Date(),
  }

  users.push(newUser)
  return ok([newUser], 201)
}

要点:route.ts 里每个 HTTP 方法是独立导出的函数(GET/POST),不支持的请求方法由框架直接返回 405;请求体经 zod 的 safeParse 校验后才使用,避免 as 断言带来的假类型安全。zod schema 定义见下文「类型验证工具」一节。

客户端类型安全

数据获取类型

// src/hooks/useFetch.ts
import { useState, useEffect } from 'react'

interface FetchState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

export function useFetch<T>(url: string): FetchState<T> & { refetch: () => void } {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null
  })

  const fetchData = async () => {
    setState(prev => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const result = await response.json() as { success: boolean; data: T; error?: string }

      if (result.success) {
        setState({
          data: result.data,
          loading: false,
          error: null
        })
      } else {
        setState({
          data: null,
          loading: false,
          error: result.error || '请求失败'
        })
      }
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error instanceof Error ? error.message : '未知错误'
      })
    }
  }

  useEffect(() => {
    fetchData()
  }, [url])

  return {
    ...state,
    refetch: fetchData
  }
}

类型安全的状态管理

// src/types/store.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { User } from '@/types/global'

// 用户状态类型
interface UserState {
  user: User | null
  isAuthenticated: boolean
  loading: boolean
}

// 用户操作类型
interface UserActions {
  login: (email: string, password: string) => Promise<void>
  logout: () => void
  updateUser: (updates: Partial<User>) => void
}

// 组合类型
type UserStore = UserState & UserActions

// 创建类型安全的store
export const useUserStore = create<UserStore>()(
  devtools(
    persist(
      (set, get) => ({
        // 初始状态
        user: null,
        isAuthenticated: false,
        loading: false,

        // 登录操作
        login: async (email: string, password: string) => {
          set({ loading: true })

          try {
            const response = await fetch('/api/auth/login', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ email, password })
            })

            const result = await response.json()

            if (result.success) {
              set({
                user: result.data,
                isAuthenticated: true,
                loading: false
              })
            } else {
              throw new Error(result.error)
            }
          } catch (error) {
            set({ loading: false })
            throw error
          }
        },

        // 登出操作
        logout: () => {
          set({
            user: null,
            isAuthenticated: false,
            loading: false
          })
        },

        // 更新用户信息
        updateUser: (updates: Partial<User>) => {
          const currentUser = get().user
          if (currentUser) {
            set({
              user: { ...currentUser, ...updates, updatedAt: new Date() }
            })
          }
        }
      }),
      {
        name: 'user-store',
        partialize: (state) => ({
          user: state.user,
          isAuthenticated: state.isAuthenticated
        })
      }
    )
  )
)

类型测试和验证

类型验证工具

// src/lib/validation.ts
import { z } from 'zod'

// 用户验证schema
export const userSchema = z.object({
  id: z.string().min(1),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  avatar: z.string().url().optional(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.date(),
  updatedAt: z.date()
})

// 创建用户验证schema
export const createUserSchema = z.object({
  name: z.string().min(1, '姓名不能为空').max(50, '姓名不能超过50个字符'),
  email: z.string().email('请输入有效的邮箱地址'),
  role: z.enum(['admin', 'user']).optional().default('user')
})

// 类型推断
export type CreateUserInput = z.infer<typeof createUserSchema>
export type User = z.infer<typeof userSchema>

// 验证函数
export function validateCreateUser(input: unknown): CreateUserInput {
  return createUserSchema.parse(input)
}

// 安全验证函数
export function safeValidateCreateUser(input: unknown): {
  success: boolean
  data?: CreateUserInput
  error?: string
} {
  const result = createUserSchema.safeParse(input)

  if (result.success) {
    return { success: true, data: result.data }
  } else {
    return {
      success: false,
      error: result.error.issues.map(e => e.message).join(', ')
    }
  }
}

最佳实践和优化

类型导入导出

// 使用type-only导入减少运行时依赖
import type { User, ApiResponse } from '@/types'
import { Button } from '@/components'

// 使用命名导出便于树摇
export type { User, ApiResponse }
export { Button, Card }

条件类型和映射类型

// src/types/advanced.ts
// 条件类型
type ApiResult<T> = T extends string
  ? { message: T }
  : T extends number
  ? { count: T }
  : { data: T }

// 映射类型
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type Required<T, K extends keyof T> = T & Required<Pick<T, K>>

// 使用示例
interface UserForm {
  name: string
  email: string
  password: string
}

type UpdateUserForm = Optional<UserForm, 'password'>
type LoginUserForm = Required<UserForm, 'email' | 'password'>

工具类型的使用

// 内置工具类型
interface PartialUser {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

// 选择部分属性
type UserBasicInfo = Pick<PartialUser, 'name' | 'email'>

// 排除某些属性
type UserWithoutId = Omit<PartialUser, 'id'>

// 使所有属性可选
type PartialUserInfo = Partial<PartialUser>

// 使所有属性必需
type RequiredUser = Required<PartialUser>

// 属性重命名
type UserWithDisplayName = {
  [K in keyof PartialUser as K extends 'name' ? 'displayName' : K]: PartialUser[K]
}

总结

正文验证:未知 JSON 先收窄再使用

下面是本页“类型断言不验证外部 JSON”的最小 TypeScript 契约。验证脚本会直接从此 Markdown 代码块提取内容并以 tsc --strict --noEmit 检查;它验证类型收窄,不构建 Next.js 应用或调用网络。

type Todo = { id: string; title: string; done: boolean };

function isTodo(value: unknown): value is Todo {
  if (typeof value !== 'object' || value === null) return false;
  const record = value as Record<string, unknown>;
  return typeof record.id === 'string'
    && typeof record.title === 'string'
    && typeof record.done === 'boolean';
}

const response: unknown = { id: '1', done: false };
if (isTodo(response)) {
  const title: string = response.title;
  void title;
} else {
  // 缺少 title 的输入不会被断言伪装成 Todo。
  const rejected: true = true;
  void rejected;
}

TypeScript 提高了重构和协作时发现错误的机会,但不会验证浏览器、URL、数据库或第三方服务送来的数据;每个跨边界值仍需要运行时检查。

相关阅读

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇