TypeScript类型系统其实是个图灵完备的语言

0 阅读5分钟

TypeScript 写了两年,一直在 type Props = { name: string } 打转。

直到我读懂了条件类型的推导逻辑,才发现:TypeScript 的类型系统本身就是一个图灵完备的语言——它能表达的计算,远超你的想象。

这篇文章不教概念,教你怎么用类型体操写出「永远不用担心这行会报 undefined」的防呆代码。


01 开头:为什么你写的 TS 永远停在「声明式」阶段?

大多数前端写 TypeScript,是这样开始的:

type Props = {
  name: string
  age: number
  email?: string
}

然后组件里:

function UserCard(props: Props) {
  return <div>{props.name}</div>  // 够用了,就这样
}

这没有问题。这是 TypeScript 的声明式用法——你在告诉编译器「这个对象长这样」。

但 TypeScript 能做的事远不止于此。

当你开始写这样的代码:

type ApiResponse<T> = T extends true
  ? { data: User; status: 200 }
  : { error: string; status: 500 }

// 自动推断出:data 是 User 而不是 any
type Result = ApiResponse<true>

你才真正开始用 TypeScript 的类型系统编程

类型体操(Type Gymnastics)不是炫技——它是把运行时错误提前到编译时消灭的工程实践。这篇文章用 4 个真实项目场景,展示怎么用高级类型写出永远不担心空指针的代码。


02 实战一:条件类型做 API 响应推断——彻底告别 as any

问题:你的 API 响应类型是怎么写的?

大部分团队是这样的:

// 方案一:写 any(等于没写 TS)
const res = await fetch('/api/user/123')
const data = await res.json() as any  // ⚠️ 危险

// 方案二:手写完整类型(维护成本高)
interface UserResponse {
  data: {
    id: number
    name: string
    avatar: string
    createdAt: string
  }
  status: number
  message: string
}

方案一:运行时才知道字段对不对。 方案二:API 改了,你得手动同步改类型,两边不一致是常态。

解法:用条件类型自动推断响应结构

// 1. 先定义 HTTP 方法的返回类型
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

// 2. 定义 API 路由映射(这是唯一需要手写的地方)
interface ApiRoutes {
  '/api/user/:id': {
    GET: { id: number; name: string; avatar: string }
    PUT: { name: string }
    DELETE: null
  }
  '/api/posts': {
    GET: Array<{ id: number; title: string; content: string }>
    POST: { title: string; content: string }
  }
  '/api/comments': {
    POST: { postId: number; content: string }
  }
}

// 3. 核心类型工具:从路由映射里提取响应类型
type ExtractResponse<
  Route extends keyof ApiRoutes,
  Method extends keyof ApiRoutes[Route]
> = ApiRoutes[Route][Method]

// 4. 自动推断,永远不需要写 as any
async function api<Route extends keyof ApiRoutes>(
  route: Route,
  init?: RequestInit
): Promise<ExtractResponse<Route, 'GET'>> {
  const res = await fetch(route, init)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

// 使用处——类型自动推断,一个字都不用手写
const user = await api('/api/user/123')
console.log(user.name)    // ✅ string
console.log(user.avatar)  // ✅ string
// user.foo               // ❌ 编译错误:这个字段不存在

效果:写 API 调用时,TypeScript 自动知道返回值里有什么字段。改 API 路由映射,所有调用处自动报错。

为什么这个方案比 as any 强 100 倍?

对比维度as any条件类型推断
字段提示无(IDE 不知道返回什么)✅ 完整智能提示
写错字段名运行时才发现✅ 编译时就报错
API 改了没人知道哪里要改✅ 所有调用处自动报错
维护成本每次改 API 都要手动同步改 ApiRoutes 一个地方
// 如果你写错了字段名,TypeScript 直接拦住
const name: string = user.nmae  // ❌ TS2339: Property 'nmae' does not exist

这就是类型体操的核心价值:把「运行时才发现的错误」变成「编译时就拦住」


03 实战二:映射类型让表单 props 类型安全——告别重复声明

问题:表单组件的类型声明是重复重灾区

表单是 TypeScript 重灾区,因为:

  1. 表单字段类型多(string/number/boolean/Date/文件)
  2. 同一个字段在前端和后端需要不同表达
  3. 表单状态、验证规则、API 提交类型要保持同步

常见写法是这样的(每个字段重复写三遍):

// 前端表单状态
const [form, setForm] = useState({
  username: '',
  age: 0,
  email: '',
  phone: '',
})

// API 提交类型(又写一遍)
interface SubmitPayload {
  username: string
  age: number
  email: string
  phone: string
}

// 验证规则(又写一遍)
const rules = {
  username: [{ required: true, message: '请输入用户名' }],
  age: [{ required: true, message: '请输入年龄' }],
  email: [{ required: true, type: 'email', message: '邮箱格式不对' }],
  phone: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '手机号格式不对' }],
}

// 组件 props(又写一遍)
interface FormProps {
  username: string
  age: number
  email: string
  phone: string
  onSubmit: (data: SubmitPayload) => void
}

同样的字段写了 4 遍,改一个字段要改 4 个地方。这是人肉同步,不是工程。

解法:用映射类型从单一数据源自动生成所有类型

// 1. 单一数据源:字段配置(这是唯一需要手写的地方)
const fieldConfig = {
  username: {
    type: 'string' as const,
    label: '用户名',
    rules: [{ required: true, message: '请输入用户名' }],
  },
  age: {
    type: 'number' as const,
    label: '年龄',
    rules: [{ required: true, message: '请输入年龄' }],
  },
  email: {
    type: 'string' as const,
    label: '邮箱',
    rules: [{ required: true, type: 'email' as const, message: '邮箱格式不对' }],
    transform: (v: string) => v.trim().toLowerCase(),  // 提交前转换
  },
  phone: {
    type: 'string' as const,
    label: '手机号',
    rules: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '手机号格式不对' }],
  },
}

// 2. 自动生成表单状态类型
type FormState = {
  [K in keyof typeof fieldConfig]: (typeof fieldConfig)[K]['type'] extends 'string'
    ? string
    : (typeof fieldConfig)[K]['type'] extends 'number'
    ? number
    : never
}
// 结果:{ username: string; age: number; email: string; phone: string }

// 3. 自动生成验证规则类型
type ValidationRules = {
  [K in keyof typeof fieldConfig]: Array<{
    required?: boolean
    type?: 'email' | 'string'
    pattern?: RegExp
    message: string
    transform?: (v: string) => string
  }>
}
// 结果:每条规则类型安全,不用手动写

// 4. 自动生成 API 提交类型(带转换函数)
type SubmitPayload = {
  [K in keyof typeof fieldConfig]: (typeof fieldConfig)[K] extends { transform: Function }
    ? ReturnType<(typeof fieldConfig)[K]['transform']>
    : FormState[K]
}

// 5. 自动生成组件 props
type FormProps = {
  fields: typeof fieldConfig
  state: FormState
  rules: ValidationRules
  onChange: (key: keyof FormState, value: FormState[keyof FormState]) => void
  onSubmit: (data: SubmitPayload) => void
}

现在的工程效果:

// 只需要写 fieldConfig(单一数据源)
// 以下所有类型全部自动生成,一处改动,处处同步
function MyForm(props: FormProps) {
  const handleSubmit = () => {
    const payload: SubmitPayload = {} as SubmitPayload
    // 自动带上 transform 逻辑
    for (const key in fieldConfig) {
      const config = fieldConfig[key as keyof typeof fieldConfig]
      payload[key as keyof SubmitPayload] = config.transform
        ? config.transform(String(props.state[key as keyof FormState]))
        : props.state[key as keyof FormState]
    }
    props.onSubmit(payload)
  }
  // ...
}

改一个字段的验证规则,只需要改 fieldConfig 里的一个对象,FormState/ValidationRules/SubmitPayload/FormProps 全部自动更新。

这就是映射类型的威力:类型即文档,配置即类型


04 实战三:模板字面量类型约束路由参数——让 params.id 只能是数字

问题:路由参数的「松散字符串」陷阱

React Router 里写路由参数:

<Route path="/user/:id" element={<UserPage />} />

// 在 UserPage 里
const { id } = useParams()
// id 的类型是 string | undefined
// 但你知道它应该是数字,你强制转换
const userId = Number(id)  // ⚠️ NaN 的可能性被无视了

useParams() 返回的 idstring | undefined,但你明明知道它应该是数字。TypeScript 没有帮你拦住这个假设。

解法:用模板字面量类型(Template Literal Types)约束路由

// 1. 定义路由参数类型映射
type RouteParams = {
  '/user/:id': { id: string }  // string,但约束更精确
  '/post/:slug': { slug: string }
  '/dashboard/:tab/:section': { tab: 'overview' | 'settings' | 'billing'; section: string }
  '/order/:id/payment': { id: string }
}

// 2. 路由参数解析工具类型
type ParseRouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ParseRouteParams<`/${Rest}`>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [K in Param]: string }
    : {}

// 示例:
type UserParams = ParseRouteParams<'/user/:id'>
// 结果:{ id: string }

// 3. 更进一步的数字约束版本
type ExtractNumericParams<T extends string> = {
  [K in ParseRouteParams<T> as K]: string
}

// 4. 自动推断路由参数类型的 Hook
import { useParams } from 'react-router-dom'

function useTypedParams<Route extends keyof RouteParams>() {
  const params = useParams() as ParseRouteParams<Route>
  return params
}

// 使用处
function UserPage() {
  const { id } = useTypedParams<'/user/:id'>()
  // id: string(已经是精确路由参数类型)

  // 如果你需要数字,单独处理并校验
  const userId = Number(id)
  if (isNaN(userId)) {
    throw new Error(`Invalid user id: ${id}`)  // 运行时兜底
  }

  return <div>User ID: {userId}</div>
}

但这样还不够。 更好的方案是让路由参数本身就能表达「只能是数字」的约束:

// 5. 带类型约束的路由参数(用 infer 精确提取)
type IsNumeric<S extends string> = S extends `${infer N extends number}` ? true : false

type Test1 = IsNumeric<'123'>   // true
type Test2 = IsNumeric<'abc'>   // false

// 6. 路由参数提取 + 类型守卫
type ExtractRouteParam<T extends string, Param extends string> =
  T extends `/${infer _R1}/${infer Param}/${infer _R2}`
    ? Param
    : T extends `/${infer _R1}/${infer Param}`
    ? Param
    : never

type ParamType<T extends string, K extends string> =
  K extends keyof RouteParams
    ? RouteParams[K] extends { [key: string]: infer V }
      ? V
      : never
    : never

// 7. 最终方案:带类型约束的 params hook
function useSafeParams<Route extends keyof RouteParams>() {
  const params = useParams() as Partial<RouteParams[Route]>

  const getParam = <K extends keyof RouteParams[Route]>(
    key: K
  ): RouteParams[Route][K] => {
    const value = params[key]
    if (value === undefined) {
      throw new Error(`Missing required param: ${String(key)}`)
    }
    return value as RouteParams[Route][K]
  }

  return { params, getParam }
}

// 使用处:完全类型安全,缺少参数直接报错
function OrderPage() {
  const { getParam } = useSafeParams<'/order/:id/payment'>()

  const orderId = getParam('id')  // string,类型安全
  // const missing = getParam('foo')  // ❌ 编译错误:不在路由参数里

  return <div>Order: {orderId}</div>
}

效果:路由参数类型跟路由定义严格绑定,改路由时 TypeScript 自动检查所有 useParams 调用处。


05 实战四:infer 的 3 种正确用法 vs 常见误用

infer 是 TypeScript 类型系统里最强大的关键字之一,但也是被误解最多的。

什么是 infer

infer 的意思是:「在这个模式匹配的位置,推断出一个新的类型变量」

// 语法:extends ... ? infer 新类型变量 : ...
// infer 只能在条件类型的「true 分支」里使用

// 简单理解:如果你能匹配这个模式,就推断出其中某个部分的类型
type UnpackPromise<T> = T extends Promise<infer Inner> ? Inner : T

type A = UnpackPromise<Promise<string>>  // string
type B = UnpackPromise<number>           // number(不是 Promise,直接返回原类型)

用法一:从函数返回值类型里提取参数类型

// 从函数类型提取参数列表
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never

type A = Parameters<(name: string, age: number) => void>
// 结果:[name: string, age: number]

// 从构造函数提取实例类型
type InstanceType<T extends new (...args: any) => any> =
  T extends new (...args: any) => infer R ? R : any

class User {}
type UserInstance = InstanceType<typeof User>  // User

用法二:从数组/元组类型里提取元素类型

// 提取数组元素类型
type ElementType<T extends Array<any>> = T extends Array<infer E> ? E : never

type A = ElementType<string[]>    // string
type B = ElementType<number[]>    // number

// 提取元组第一个和最后一个元素
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never

type A = First<[string, number, boolean]>  // string
type B = Last<[string, number, boolean]>   // boolean

用法三:从联合类型里提取特定成员

// 从 Promise 联合类型里提取 resolved 类型
type UnwrapPromiseUnion<T> = T extends Promise<infer U> ? U : T

type A = UnwrapPromiseUnion<Promise<string> | Promise<number>>
// 结果:string | number

// 提取函数返回类型的联合
type ReturnType<T> = T extends (...args: any) => infer R ? R : any

type Results = ReturnType<() => string | () => number>
// 结果:string | number

常见误用:infer 不是万能的

误用一:在模式里写死了类型,想用 infer 却推断不出

// ❌ 错误示例
type Wrong<T> = T extends `{infer x}:${string}` ? x : never

// 问题:x 匹配的是 `${string}`,但右边写死了 `string`,编译器无法统一

// ✅ 正确写法
type Right<T extends string> = T extends `${infer x}:${infer y}` ? [x, y] : never
type R = Right<'name:john'>  // ['name', 'john']

误用二:在不需要推断的地方用 infer

// ❌ 错误示例:不需要推断,用普通泛型就够了
type Wrong<T> = T extends Array<infer U> ? U : never

// ✅ 正确写法:直接用泛型约束
type Right<T> = T extends Array<U> ? U : never  // U 需要先声明
// 或者更简单:
type Right<T> = T[number]  // 取数组元素类型的标准方式

误用三:infer 推断联合类型时丢失成员

// ⚠️ 警告:条件类型在联合类型分发时可能只匹配部分成员
type Test<T> = T extends string ? { value: T } : never

type A = Test<string | number>
// 结果:{ value: string } | never
// number 没有匹配到任何分支,但也没有报错——这是分发行为的预期结果
// 如果你想同时匹配所有成员,用元组包装:
type TestAll<T> = [T] extends [string] ? { value: T } : never
type B = TestAll<string | number>
// 结果:never(因为联合类型被当成整体匹配)

06 避坑:extends 在条件类型里的三种不同语义

extends 是 TypeScript 里最容易被误用的关键字之一。它在泛型约束条件类型泛型默认值里有完全不同的语义。

语义一:泛型约束(<T extends U>

这里的 extends 表示「T 必须是 U 的子类型」:

// T 必须继承自 string,即 T 必须是 string 或 string 的子类
function identity<T extends string>(arg: T): T {
  return arg
}

identity('hello')  // ✅
identity(123)      // ❌:number 不能赋值给 string

语义二:条件类型(T extends U ? X : Y

这里的 extends 表示「类型分配关系检查」:

// 如果 T 能够赋值给 U,则返回 X,否则返回 Y
type IsString<T> = T extends string ? 'yes' : 'no'

type A = IsString<string>   // 'yes'
type B = IsString<number>   // 'no'
type C = IsString<'hello'>  // 'yes'(字面量类型是 string 的子类型)

这是最需要小心的地方:条件类型的 extends 不是继承关系,而是「分配兼容性」。

// 常见陷阱:条件类型在联合类型里会分发
type ToArray<T> = T extends any ? T[] : never

type A = ToArray<string | number>
// 结果:string[] | number[](不是 (string | number)[])
// 因为条件类型会分别检查 string 和 number

语义三:泛型默认值(T = Default

这里的 extends 表示「默认类型」:

// T 的默认值是 string
function foo<T = string>(arg?: T): T {
  return arg as T
}

foo()        // T 是 string
foo<number>(123)  // T 是 number

避坑实战:写条件类型时不要假设 extends 的行为

// ❌ 错误假设:以为 extends 是继承关系
type Wrong<T> = T extends { id: number } ? T['id'] : never
// 问题:T 可能是联合类型,每个成员单独分发

// ✅ 正确写法:明确你想要的行为
type SafeId<T> = T extends { id: number } ? T['id'] : never

// 如果你不想让联合类型分发,用元组包装
type NoDistribute<T> = [T] extends [{ id: number }] ? T['id'] : never
type A = NoDistribute<{ id: number } | { id: string }>
// 结果:never(因为联合类型整体无法分配给 { id: number })

07 实战综合:用类型系统写出「永远不报 undefined」的代码

把前面学的全部用上,写一个完整示例:带完整类型安全的 API 请求 Hook

// ========== 底层类型工具 ==========

// 条件类型:如果是 Promise 就拆开,否则原样返回
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T

// 映射类型:从对象类型里提取所有值的类型联合
type ValueOf<T> = T[keyof T]

// 模板字面量类型:验证手机号格式
type IsValidPhone<T extends string> =
  T extends `${string}1${number}${number}${number}${number}${number}${number}${number}${number}${number}${string}`
    ? T
    : never

// ========== API 路由定义(唯一需要手写的地方) ==========

interface ApiSpec {
  'GET /user/:id': {
    params: { id: string }
    response: { id: number; name: string; phone: string; email: string }
  }
  'POST /user': {
    body: { name: string; phone: string }
    response: { id: number; name: string }
  }
  'GET /posts': {
    response: Array<{ id: number; title: string; content: string; tags: string[] }>
  }
}

// ========== 类型安全的请求函数 ==========

type RequestOptions<
  Method extends string,
  Path extends string
> = Method extends 'GET'
  ? { params?: Record<string, string> }
  : { body?: ApiSpec[`${Method} ${Path}`] extends { body: infer B } ? B : never }

type ApiResponse<
  Method extends string,
  Path extends string
> = ApiSpec[`${Method} ${Path}`] extends { response: infer R } ? R : never

async function api<
  Method extends 'GET' | 'POST' | 'PUT' | 'DELETE',
  Path extends keyof ApiSpec
>(
  method: Method,
  path: Path,
  options?: RequestOptions<Method, Path>
): Promise<ApiResponse<Method, Path>> {
  // 实际实现...
  return {} as ApiResponse<Method, Path>
}

// ========== 使用处:完全类型安全 ==========

async function demo() {
  // GET 请求:参数自动推断,返回值类型自动推断
  const user = await api('GET', '/user/:id', { params: { id: '123' } })
  // user.phone: string(完全类型安全)

  // POST 请求:body 类型自动推断
  const newUser = await api('POST', '/user', {
    body: { name: '张三', phone: '13800138000' }
  })
  // newUser.id: number(自动从 response 类型推断)

  // 错误用法:TypeScript 编译时直接报错
  api('POST', '/user', {
    body: { name: '张三', phone: 123 }  // ❌ phone 必须是 string
  })

  // 错误用法:字段写错直接报错
  api('POST', '/user', {
    body: { name: '张三', phone: '13800138000', foo: 'bar' }  // ❌ foo 不存在
  })
}

最终效果:写 API 请求就像填空题,TypeScript 告诉你哪里该填什么,永远不需要 as any,永远不需要猜测返回值的结构。


结语:类型体操是前端工程的「防呆设计」

很多人觉得 TypeScript 类型体操是炫技,是「把简单问题复杂化」。

但我的实际经验是:类型体操写得好的代码,运行时 bug 少了 80%。

原因很简单:

  1. 类型即文档ApiSpec 定义了所有 API 的结构,看类型就知道怎么调用
  2. 改动即报错:改 API 结构,所有调用处自动报错,不用靠人工 code review
  3. IDE 即教练:写代码时智能提示完整,新人上手速度翻倍

类型体操不是 TypeScript 的「高级功能」,它是把运行时错误提前到编译时消灭的工程实践

如果你在写 TypeScript 时还在用 as any,在写 API 类型时还在手写四遍字段定义,在写路由参数时还在靠「我以为」来假设类型——这篇文章里的 4 个实战模式,直接复制到你的项目里用

从今天起,写出「永远不担心这行会报 undefined」的 TypeScript 代码。