
先说个我带新人时最常见的场景。
一个刚上手的同事写了个列表页,请求接口、渲染数据。类型标得规规矩矩,data、error、loading 一个不落。我看了眼,问他:"如果接口又返回了数据、又报了错,你这页面显示啥?"
他卡住了。
其实新手和资深写 TS 的差别,真不在谁背的语法多。新手是把 TS 当"给变量贴个类型标签",标完就完事;资深是在用类型系统帮自己挡 bug——让一整类错误根本没机会跑出来。
下面这 10 个技巧,我全部换成你写业务时天天遇到的场景来讲,尽量说人话。看完你大概率会有几个"哦原来这玩意儿是干这个的"的瞬间。
场景:你写一个数据列表,有三种状态——加载中、加载成功、加载失败。新手一般这么标:
type PageState = {
data?: User[] // 成功了才有
error?: string // 失败了才有
loading?: boolean // 加载中为 true
}
看起来没毛病。但问题是:这三个字段互相之间没有任何约束。你完全可以写出 { loading: true, error: '出错了', data: [...] } 这种鬼东西——同时加载中、同时报错、同时又有数据。到底该显示啥?没人知道。
资深怎么写:把三种状态各写成一个独立形状,用一个 status 字段当"身份标识"。
type PageState =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; message: string }
function render(state: PageState) {
switch (state.status) {
case'loading':
return <Spinner />
case'success':
return <List data={state.data} /> // 这里 TS 保证有 data
case'error':
return <Error msg={state.message} /> // 这里 TS 保证有 message
}
}
人话总结:以前是"一张表上勾三个可选框,随便勾",现在是"三选一的单选题"。data 只在成功时存在,message 只在失败时存在,那些"又转圈又报错"的非法组合,直接从类型层面被删掉了。
日常业务里一大半的"状态判断乱套",根子都在这——本该单选的地方用了一堆可选字段。这一条是全篇我最想你记住的。
satisfies:既检查配置写没写错,又不丢掉精确类型场景:你维护一个主题配置对象。你希望 TS 帮你检查"别把字段名拼错、别漏字段",但又想在别处用到 theme 时它还记得具体是 'dark'。
新手加个类型注解就完事了:
type Config = {
theme: 'light' | 'dark' | 'system'
colors: Record<string, string>
}
const config: Config = { theme: 'dark', colors: { primary: '#3d6fe8' } }
// 问题:config.theme 现在被 TS 认成 'light' | 'dark' | 'system'
// 它"忘了"你写的其实是 'dark'
加了注解,TS 就把值"放大"到了注解的范围,具体是哪个它不记得了。
**资深用 satisfies**:
const config = {
theme: 'dark',
colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config
// config.theme 还是精确的 'dark'
// 但字段名拼错了照样报错
人话总结:: Config 是"按这个模子把值压成标准件",satisfies Config 是"拿这个模子量一下合不合格,但东西还是你原来那个精确的东西"。想要校验、又不想丢精度,就用它。
场景:项目里有一堆接口路径,你想约束别人只能调已定义的路径,写错了直接报错。手写一长串联合类型太蠢了。
type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
// 用模板把两个联合"叉乘"起来
type ApiEndpoint = `${HttpMethod} ${Route}`
// TS 自动算出 3 × 3 = 9 种组合:'GET /users' | 'GET /orders' | ...
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // ✅ 通过
callApi('PATCH /users') // ❌ 报错,PATCH 不在允许范围
人话总结:就像 Excel 里"方法列 × 路径列"自动生成所有组合,你一个都不用手写。哪天加个 PUT 方法,改一个字,9 种自动变 12 种,不用手动维护那张表。事件名(onClick)、CSS 变量(--primary)同理。
场景:这是真会出线上事故的一类 bug。你有 userId 和 orderId,俩都是字符串。
type UserId = string
type OrderId = string
function deleteOrder(id: OrderId) { /* 删订单 */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // ⚠️ TS 一声不吭,但你把用户ID传给了删订单接口
因为在 TS 眼里,UserId 和 OrderId 都是 string,长得一样就是一样,它分不清。
**资深给它们各挂个"隐形标签"**:
type Brand<T, B> = T & { readonly _brand: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
const asUserId = (id: string) => id as UserId
const asOrderId = (id: string) => id as OrderId
const userId = asUserId('user-42')
deleteOrder(userId) // ❌ 报错:UserId 不能当 OrderId 用
那个 _brand 字段运行时根本不存在,纯粹是贴给编译器看的标签,就像给两瓶长得一模一样的液体贴上"食用油"和"机油"——化学成分随便,反正贴了标签你就不会拿错。
人话总结:凡是"看着是普通字符串/数字,但含义很重要不能搞混"的值——用户ID、订单ID、邮箱、金额(元 vs 分)、时间(秒 vs 毫秒)——都值得包一层品牌。我见过把秒当毫秒传、时间差了一千倍的事故,就是这一步能挡住的。
infer:从一个复杂类型里"抠"出你想要的那部分场景:你有个函数返回一个对象,你想单独拿到"它返回值的类型",但不想手动再写一遍。
// 自己实现一个"提取返回值类型"的工具
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function getUser() {
return { id: 1, name: 'Alice' }
}
type User = MyReturnType<typeof getUser> // { id: number; name: string }
再比如接口返回的是 Promise<User>,你想拿到里面的 User:
type Unwrap<T> = T extends Promise<infer V> ? V : T
type Data = Unwrap<Promise<User>> // User
人话总结:infer V 你可以读成"这里藏着个类型,我先给它起名叫 V,你帮我认出来填进去"。就像快递单上"收件人"那一栏,你不知道具体是谁,但先留个位置,系统自动填。TS 内置的 ReturnType、Awaited 就是这么实现的。这条是进阶地基,第 7、10 条都要用它。
never 兜底:加了新状态却忘了处理,编译器直接喊你场景:接着第 1 条。项目跑了半年,产品说列表要加个"空数据"状态。你在类型里加了 'empty',但代码里有七八个地方 switch 了这个状态——你怎么保证一个都没漏?
靠人肉记忆肯定漏。资深的做法是在 switch 的 default 里放一个"陷阱":
function assertNever(value: never): never {
thrownewError(`忘记处理这个状态了: ${JSON.stringify(value)}`)
}
function render(state: PageState) {
switch (state.status) {
case'loading': return <Spinner />
case'success': return <List data={state.data} />
case'error': return <Error msg={state.message} />
default:
// 你加了 'empty' 却忘了写 case,
// TS 会在这一行直接编译报错,根本发布不出去
return assertNever(state)
}
}
原理(说人话):当上面所有 case 都处理完了,走到 default 时 state 已经"没剩啥了"(类型是 never);一旦你漏了 'empty',state 到这儿还剩一个具体类型,塞不进只收 never 的 assertNever,于是报错。
它和第 1 条是一对搭档:可辨识联合负责建模,assertNever 负责"你改动时一个都别想漏"。它俩的配合大概是这样:
定义 PageState(三种状态)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
loading success error
│ │ │
└──────────────┼──────────────┘
▼
switch(state.status)
│
都处理了吗?
┌────────┴────────┐
是 否(漏了)
│ │
▼ ▼
正常渲染 state 还是具体类型
│
▼
塞给 assertNever(never)
│
▼
✗ 编译期报错
人话总结:它把"我改代码时会不会漏"这件事,从"靠自觉"变成了"编译器帮你查"。加状态不再心慌。
场景:你有个 User 类型,现在要为它生成一套 getter(getId、getName……)。手写当然行,但字段一多就想吐。
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// 自动生成:
// { getId: () => string; getName: () => string; getEmail: () => string }
再比如"只挑出一个类型里的方法,过滤掉普通属性":
type FunctionKeys<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void; delete: () => void } —— id、name 被过滤掉了
人话总结:把它想成"批量重命名 + 批量筛选"。as 后面决定新名字(拼字符串),把不要的键映射成 never 就自动丢掉。第 3 条的模板字符串在这儿又用上了——这些技巧本来就是配套的。
场景:你接口返回的 user 可能是 null。你明明在前面判断过它不是 null,但后面用 user.name 时 TS 还是红线警告,烦不烦?
先看基础版——教 TS 怎么区分两个类型:
type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog
// 返回值写 `animal is Cat`,就是在告诉 TS:这函数能证明它是猫
function isCat(animal: Animal): animal is Cat {
return animal.kind === 'cat'
}
function speak(animal: Animal) {
if (isCat(animal)) {
console.log(animal.meows) // TS 认定这里是 Cat
} else {
console.log(animal.barks) // TS 认定这里是 Dog
}
}
再看那个最常见的 null 烦恼,用"断言函数"一劳永逸:
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === null || val === undefined) {
throw new Error('值不能为空')
}
}
const user = getUser() // 类型是 User | null
assertDefined(user) // 如果是 null 直接抛错
console.log(user.name) // 这行之后,TS 认定 user 就是 User,不再红线
人话总结:animal is Cat 是"我拍胸脯保证这是猫,TS 你信我";asserts val is ... 是"这行代码跑过之后,这个值肯定不为空,往后你别再警告我"。那些"我判断过了 TS 还不认账"的情况,八成就是缺个守卫。
as const:把配置数据"锁死",再从它自动推导类型场景:你有一个角色列表,还想要一个"只能是这几个角色之一"的类型。新手会写两遍——数组一份、联合类型一份,改的时候两边都得改,还容易忘。
// 不加 as const:TS 把它认成普通 string[],信息全丢了
const ROLES = ['admin', 'editor', 'viewer'] // string[]
// 加上 as const:锁死成精确的字面量
const ROLES = ['admin', 'editor', 'viewer'] as const
// 然后从数组里"反推"出联合类型
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
配置对象也一样:
const ROUTES = { home: '/', users: '/users', profile: '/profile' } as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'
人话总结:数据只写一份(加 as const),类型用 typeof 从数据里自动长出来。以后给 ROLES 加个 'guest',Role 类型自动就多一个成员——你不用维护第二份清单,永远不会两边对不上。
场景:一个查用户的接口,多传一个 includePosts: true 就连帖子一起返回,不传就只返回用户。你希望 TS 能精确知道每次拿到的是哪种结构。
type UserResponse<T extendsboolean> =
T extendstrue
? { user: User; posts: Post[] } // 传 true:带 posts
: { user: User } // 传 false:不带
function fetchUser<T extends boolean>(id: string, includePosts: T): Promise<UserResponse<T>> {
/* 实现 */
}
const r1 = await fetchUser('123', true)
r1.posts // ✅ TS 知道有 posts
const r2 = await fetchUser('123', false)
r2.posts // ❌ 报错:这次的返回结构里没有 posts
再看个更贴业务的——发文章,草稿和发布对字段的要求不一样:
type ArticleInput<M extends 'draft' | 'publish'> =
M extends 'publish'
? { title: string; content: string; publishedAt: Date } // 发布:全必填
: { title: string; content?: string } // 草稿:只要标题
function createArticle<M extends 'draft' | 'publish'>(mode: M, input: ArticleInput<M>) {}
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() }) // ✅
createArticle('draft', { title: 'Hey' }) // ✅ 草稿不用填正文和时间
人话总结:"草稿可以只有标题,发布必须带正文和时间"这种业务规则,过去要么运行时写一堆 if 校验,要么写在文档里靠口口相传。条件类型把规则直接刻进了函数签名——传错了根本编译不过。
别把它们当成 10 个孤立的小技巧。串起来看,它们说的是一件事:把类型当成一层"逻辑防线",而不只是给变量贴的标签。
可辨识联合负责把状态建模清楚,never 兜住每一次改动,品牌类型守住"别搞混"的边界,infer 和条件类型让类型会"算账",as const 让数据成为类型的唯一源头……它们层层咬合,指向同一个目标:
新手和资深的差距,不是谁会的语法多,而是资深开发者用类型系统,在 bug 跑起来之前就把一整类错误消灭了。
说白了,好的 TypeScript 不是给你写好的代码"配个说明书",而是让写错的代码根本编译不过去。
// 01 可辨识联合 —— 页面状态别用一堆可选字段,改成"三选一"
type State<T> = { status: 'loading' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02 satisfies —— 校验配置但不丢精确类型
const cfg = { theme: 'dark' } satisfies Config
// 03 模板字面量 —— 路由/事件名让 TS 自动拼组合
type Endpoint = `${'GET' | 'POST'} ${'/users' | '/orders'}`
// 04 品牌类型 —— 防止 userId 当 orderId 传
type UserId = string & { readonly _brand: 'UserId' }
// 05 infer —— 从 Promise<User> 里抠出 User
type Unwrap<T> = T extendsPromise<infer V> ? V : T
// 06 assertNever —— 加了新状态忘处理,编译期报错
function assertNever(x: never): never { thrownewError('漏了个状态') }
// 07 映射类型 —— 批量生成 getter,不手写
type Getters<T> = { [K in keyof T as`get${Capitalize<string & K>}`]: () => T[K] }
// 08 类型守卫 —— 判断过 null 后让 TS 别再警告
function assertDefined<T>(v: T): asserts v is NonNullable<T> { if (v == null) throwError() }
// 09 as const —— 角色数组只写一份,类型自动推导
const ROLES = ['admin', 'editor'] asconst
type Role = typeof ROLES[number]
// 10 条件类型 —— 传不同参数返回不同结构
type Res<T extendsboolean> = T extendstrue ? { data: User; posts: Post[] } : { data: User }
问你个我自己也纠结过的问题:
这 10 个里,如果只能在团队里先推一个当规范,你会选哪个?
我个人会选 01 可辨识联合——它拦下的 bug 最多,但学习成本最低,新人一天就能上手。不过我也见过有团队把 04 品牌类型 当命根子,因为他们真踩过"金额单位搞混、少算一百倍"的坑,赔到肉疼。
评论区聊聊:你在真实项目里,靠哪个技巧救过命?或者哪个你觉得"看着酷,实际用不上"?