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 重灾区,因为:
- 表单字段类型多(string/number/boolean/Date/文件)
- 同一个字段在前端和后端需要不同表达
- 表单状态、验证规则、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() 返回的 id 是 string | 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%。
原因很简单:
- 类型即文档:
ApiSpec定义了所有 API 的结构,看类型就知道怎么调用 - 改动即报错:改 API 结构,所有调用处自动报错,不用靠人工 code review
- IDE 即教练:写代码时智能提示完整,新人上手速度翻倍
类型体操不是 TypeScript 的「高级功能」,它是把运行时错误提前到编译时消灭的工程实践。
如果你在写 TypeScript 时还在用 as any,在写 API 类型时还在手写四遍字段定义,在写路由参数时还在靠「我以为」来假设类型——这篇文章里的 4 个实战模式,直接复制到你的项目里用。
从今天起,写出「永远不担心这行会报 undefined」的 TypeScript 代码。