先说个真事
上个月组里来了个新人,工作两年,简历上写着"精通TypeScript"。
第一天他就跟我说:"TypeScript就是JavaScript加个类型标注,有啥难的?"
我笑了笑没说话。
一周后,他提交的代码炸了。测试同学过来找他:"为什么这个订单的金额显示NaN?"
他自己看了半天代码,说:"不应该啊,我都写了类型的。"
我过去看了一眼:
function calculateTotal(order: any) { return order.items.reduce((sum, item) => sum + item.price, 0); }看出问题了吗?order.items可能是undefined,直接崩了。
"你不是写了类型吗?" 测试同学问。
"我写了啊,你看,order: any..." 他突然意识到了什么。
对,any就是"我放弃治疗"的意思。编译器看到any就不管了,你写啥都行,能不能跑就看运气了。
这就是我想说的:90%的人以为自己在用TypeScript,其实只是在用"带类型注释的JavaScript"。
今天我们就聊聊那些真正能救命的TypeScript技巧——每个都是我或者我同事踩过坑之后的血泪经验。
一、类型定义到处复制?你需要知道"类型也能用下标"
我遇到的问题
去年做一个用户中心项目,后端定义了用户的数据结构。
前端各个组件都需要用到用户的某些字段,于是我就到处复制类型定义:
// 用户的完整信息 interface User { id: string; name: string; email: string; profile: { age: number; bio: string; avatar: string; address: { province: string; city: string; street: string; } } } // 个人资料组件需要profile interface UserProfile { age: number; bio: string; avatar: string; address: { province: string; city: string; street: string; } } // 地址组件需要address interface UserAddress { province: string; city: string; street: string; }看起来没啥问题对吧?
直到有一天,后端说:"我们给address加了个zipCode字段。"
我:😱
我得去找所有用到address的地方,一个个改。改了十几个文件,漏了两个,又出bug了。
后来学到的方法
其实根本不用复制,可以直接"索引"出来:
interface User { id: string; name: string; email: string; profile: { age: number; bio: string; avatar: string; address: { province: string; city: string; street: string; } } } // 直接用方括号"取出"类型,就像访问对象属性一样 type UserProfile = User['profile']; type UserAddress = User['profile']['address']; type City = User['profile']['address']['city']; // 甚至可以一直取下去这就是 Indexed Access Types(索引访问类型)。
听起来高大上,其实就是:"类型也可以像对象一样用方括号访问"。
现在后端改了address,我只需要改User这一个地方,其他地方自动就同步了。
为什么有用?
单一数据源,改一处生效
不会因为复制粘贴导致类型不一致
重构的时候省心,编译器会告诉你哪里不对
二、别用any了,学会自己教TypeScript识别类型
踩过的坑
以前写代码,遇到不确定类型的数据,我就直接any:
function processData(data: any) { console.log(data.name); // 能跑,但可能炸 }"能跑"是能跑,但data到底是个啥?有没有name这个属性?天知道。
有一次接了个后端接口,返回的数据可能是用户信息,也可能是错误信息:
const response = await fetch('/api/user'); const data = await response.json(); // data的类型是any // 我直接用了 console.log(data.name); // 如果是错误信息,这里就炸了更好的办法:Type Guard(类型守卫)
后来我学会了先"检查"一下:
// 定义一个检查函数 function isUser(data: any): data is User { return ( data && typeof data === 'object' && typeof data.id === 'string' && typeof data.name === 'string' ); } // 使用的时候先检查 const response = await fetch('/api/user'); const data = await response.json(); if (isUser(data)) { // 在这个if里面,TypeScript知道data是User类型 console.log(data.name); // ✅ 安全 console.log(data.email); // ✅ 有提示 } else { console.error('数据格式不对'); }关键在于data is User这个写法。
这是在告诉TypeScript:"如果这个函数返回true,那data就是User类型。"
听起来有点绕?换个说法:
你在教TypeScript一个判断规则——"怎么知道一个东西是User"。
之前TypeScript不知道怎么判断,现在你教它了,它就能在代码里自动推导类型了。
真实收益:
上个月重构了一个老项目,把所有any改成了Type Guard,发现了17个潜在的bug,全是"可能访问undefined的属性"这种问题。
三、再也不怕漏掉case:强制你处理所有情况
遇到的问题
写一个订单状态处理函数:
type OrderStatus = 'pending' | 'paid' | 'cancelled'; function getStatusText(status: OrderStatus) { switch (status) { case'pending': return'待支付'; case'paid': return'已支付'; case'cancelled': return'已取消'; } }看起来完美对吧?三个状态都处理了。
某天产品经理说:"加个'退款中'状态吧。"
后端改了:
type OrderStatus = 'pending' | 'paid' | 'cancelled' | 'refunding';然后你的代码编译照样通过,因为语法上没问题。
结果上线后,用户点了退款,页面啥反应都没有——因为你忘了处理refunding。
正确的做法:Exhaustive Checking
加一行代码就能避免:
function getStatusText(status: OrderStatus) { switch (status) { case'pending': return'待支付'; case'paid': return'已支付'; case'cancelled': return'已取消'; default: // 🔥 关键在这里 const _exhaustive: never = status; return _exhaustive; } }现在如果你加了refunding但忘了处理,编译就报错:
Type 'refunding' is not assignable to type 'never'.为什么有用?
如果所有case都处理了,走到
default的时候,status的类型就是never(永远不会走到这里)如果漏了某个case,那个值就会跑到
default,类型对不上就报错
用人话说:你在default放了个"绝对不会执行"的代码,如果真的执行了,说明你漏了东西。
我现在写枚举处理都会加这一行,救了我好多次。
四、既要类型检查,又要保留具体值?用satisfies
遇到的矛盾
做主题系统的时候,有个配置对象:
const theme = { primary: '#1890ff', success: '#52c41a', error: '#ff4d4f' };现在有两个需求:
要类型检查:确保所有value都是string
要保留具体值:
theme.primary的类型是'#1890ff'而不是string
为什么要保留具体值?因为后面要根据颜色值生成深浅色变体,如果类型是string就推导不出来了。
以前的办法都不完美:
// 方法1:加类型注解 type Theme = Record<string, string>; const theme: Theme = { primary: '#1890ff', // theme.primary的类型变成了string,丢失了具体值 success: '#52c41a', error: '#ff4d4f' }; // 方法2:用as const const theme = { primary: '#1890ff', // theme.primary的类型是'#1890ff',但... success: '#52c41a', error: '#ff4d4f' } asconst; // 整个对象变readonly了,不能改了完美解决:satisfies
TypeScript 4.9加的新功能:
type Theme = Record<string, string>; const theme = { primary: '#1890ff', success: '#52c41a', error: '#ff4d4f' } satisfies Theme; // 完美: // ✅ 编译器会检查是否符合Theme类型 // ✅ theme.primary的类型是 '#1890ff'(保留了具体值) // ✅ theme不是readonly,可以修改satisfies的意思就是:"这个对象满足Theme类型,但不要把它变成Theme类型"。
听起来绕,简单说就是:既要检查,又不要改变原来的类型。
五、从类型里"提取"信息:神奇的infer
实际场景
项目里有很多API函数:
async function getUserInfo(id: string): Promise<UserInfo> { // ... } async function getOrderList(page: number): Promise<OrderList> { // ... }现在问题来了:我在其他地方想用UserInfo这个类型,但我不想重复定义,怎么从函数里"提取"出来?
用infer自动推导
// 定义一个工具类型 type GetReturnType<T> = T extends (...args: any) => Promise<infer R> ? R : never; // 使用 type UserInfo = GetReturnType<typeof getUserInfo>; // 自动推导出UserInfo type OrderList = GetReturnType<typeof getOrderList>; // 自动推导出OrderListinfer是什么意思?
简单说就是:"我不知道这里是什么类型,TypeScript你帮我推导一下,推导出来的结果叫R"。
再举个例子,提取Promise的返回值:
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T; type A = UnwrapPromise<Promise<string>>; // A是string type B = UnwrapPromise<Promise<number>>; // B是number type C = UnwrapPromise<boolean>; // C是boolean(不是Promise,原样返回)执行过程(以Promise<string>为例):
TypeScript看到
T extends Promise<infer R>发现
T确实是Promise<...>的形式推导出里面的类型是
string,把它赋给R返回
R(也就是string)
更实用的例子:提取数组元素类型
type ArrayElement<T> = T extends (infer R)[] ? R : never; type Numbers = ArrayElement<number[]>; // Numbers是number type Strings = ArrayElement<string[]>; // Strings是string上周我用这个重构了整个API类型系统,原来要手写的几十个类型定义,现在都自动推导了。
六、函数参数太多容易搞混?给元组加标签
踩过的坑
以前定义一个坐标类型:
type Point = [number, number]; function drawLine(start: Point, end: Point) { const [x1, y1] = start; const [x2, y2] = end; // ... } drawLine([0, 0], [100, 50]); // 哪个是x,哪个是y?搞不清问题是:调用的时候,[0, 0]到底哪个是x,哪个是y?记不住啊。
尤其是参数多了:
type Rect = [number, number, number, number]; function drawRect(rect: Rect) { // rect是 [x, y, width, height] 还是 [left, top, right, bottom]? // 鬼知道 }Labeled Tuples(带标签的元组)
TypeScript 4.0加的功能:
type Point = [x: number, y: number]; function drawLine(start: Point, end: Point) { // ... } // 现在鼠标悬停的时候,编辑器会显示: // start: [x: number, y: number]更明显的例子:
type Rect = [x: number, y: number, width: number, height: number]; function drawRect(rect: Rect) { const [x, y, width, height] = rect; // 一目了然 }好处:
不会搞混参数顺序
编辑器有更好的提示
代码更容易理解
上个月重构地图系统的时候,把所有坐标类型都加了标签,新人上手快了很多。
七、批量生成类型:Mapped Types
遇到的需求
做表单系统,每个字段都需要验证函数:
type FormData = { username: string; email: string; password: string; age: number; }; // 需要为每个字段定义验证函数 type FormValidators = { username: (value: string) =>boolean; email: (value: string) =>boolean; password: (value: string) =>boolean; age: (value: number) =>boolean; };手写?30个字段写到吐血。
Mapped Types自动生成
type Validators<T> = { [K in keyof T]: (value: T[K]) => boolean }; type FormValidators = Validators<FormData>; // 自动生成了!怎么理解?
keyof T拿到所有key:'username' | 'email' | 'password' | 'age'[K in keyof T]遍历每个keyT[K]拿到这个key对应的类型组装成
(value: T[K]) => boolean
再举个例子,生成"所有字段都可选"的类型:
type MyPartial<T> = { [K in keyof T]?: T[K] }; type User = { id: string; name: string; email: string; }; type PartialUser = MyPartial<User>; // 相当于: // { // id?: string; // name?: string; // email?: string; // }更酷的:修改key的名字
type Getters<T> = { [K in keyof T as`get${Capitalize<K & string>}`]: () => T[K] }; type User = { name: string; age: number; }; type UserGetters = Getters<User>; // 结果: // { // getName: () => string; // getAge: () => number; // }上周用这个给200多个数据模型自动生成了getter方法的类型定义,爽歪歪。
八、强制命名规范:Template Literal Types
真实场景
组里规定:所有事件处理函数必须以handle开头。
但总有人忘记,代码review的时候很烦。
后来我用类型强制了:
type EventHandler = `handle${Capitalize<string>}`; function registerHandler(name: EventHandler, handler: Function) { // ... } registerHandler('handleClick', () => {}); // ✅ 通过 registerHandler('handleSubmit', () => {}); // ✅ 通过 registerHandler('onClick', () => {}); // ❌ 编译报错 registerHandler('click', () => {}); // ❌ 编译报错现在谁要是不按规范写,编译都过不了。
更实用的:API路由校验
type ApiRoute = `/api/${string}`; function callApi(url: ApiRoute) { fetch(url); } callApi('/api/users'); // ✅ callApi('/api/orders/123'); // ✅ callApi('/users'); // ❌ 编译报错,必须以/api/开头CSS类名规范(BEM):
type BEMClass = `${string}__${string}` | `${string}__${string}--${string}`; function addClass(className: BEMClass) { // ... } addClass('button__icon'); // ✅ block__element addClass('button__icon--active'); // ✅ block__element--modifier addClass('button-icon'); // ❌ 不符合BEM规范上个月用这个规范了整个组件库的类名,**CSS命名相关的bug少了90%**。
九、过滤类型:Distributive Conditional Types
需求场景
有个联合类型,想过滤掉null和undefined:
type MixedType = string | number | null | undefined | boolean; // 想要得到:string | number | boolean用分配条件类型
type NonNullable<T> = T extends null | undefined ? never : T; type CleanType = NonNullable<MixedType>; // 结果:string | number | boolean为什么叫"分配"?
因为TypeScript会自动把联合类型"拆开",一个个处理:
执行过程: 1. string extends null | undefined ? never : string → string 2. number extends null | undefined ? never : number → number 3. null extends null | undefined ? never : null → never 4. undefined extends null | undefined ? never : undefined → never 5. boolean extends null | undefined ? never : boolean → boolean 最后把结果合并:string | number | boolean更实用的:提取某种类型
type ExtractString<T> = T extends string ? T : never; type Mixed = string | number | boolean | string; type OnlyStrings = ExtractString<Mixed>; // string过滤掉函数类型:
type NonFunction<T> = T extends Function ? never : T; type Mixed = string | number | (() => void) | boolean; type NoFunctions = NonFunction<Mixed>; // string | number | boolean这个在处理复杂数据结构的时候特别有用。
十、配置对象别乱改:as const和readonly
血泪教训
以前有个全局配置:
const config = { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 };某天调试的时候,我改了timeout:
config.timeout = 1000; // 调试用然后忘了改回来,直接提交了。
结果线上所有请求超时时间变成1秒,大量接口超时。
用as const锁死
const config = { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 } as const; config.timeout = 1000; // ❌ 编译报错:Cannot assign to 'timeout' because it is a read-only property现在谁都改不了,想改也得先去掉as const,不会不小心改了。
另一个好处:保留字面量类型
const directions = ['north', 'south', 'east', 'west'] as const; type Direction = typeof directions[number]; // Direction = 'north' | 'south' | 'east' | 'west' // 不是 string路由配置的例子:
const routes = [ { path: '/home', component: 'Home' }, { path: '/about', component: 'About' }, { path: '/contact', component: 'Contact' } ] asconst; type RoutePath = typeof routes[number]['path']; // RoutePath = '/home' | '/about' | '/contact' function navigate(path: RoutePath) { // 只能用定义过的路由 } navigate('/home'); // ✅ navigate('/profile'); // ❌ 编译报错现在所有配置文件我都用as const,再也不担心被人误改了。
十一、处理嵌套数据:Recursive Types(递归类型)
遇到的问题
要定义JSON数据的类型,但JSON可以无限嵌套:
const data = { name: 'John', age: 30, friends: [ { name: 'Jane', age: 28, friends: [ { name: 'Bob', // 可以一直嵌套下去... } ] } ] };怎么定义类型?
递归类型定义
type Json = | string | number | boolean | null | Json[] // 数组里可以是Json | { [key: string]: Json }; // 对象的值也可以是Json const data: Json = { name: 'John', age: 30, friends: [ { name: 'Jane', age: 28, friends: [/* 无限嵌套,类型都对 */] } ] };关键在于Json的定义里引用了自己,就像递归函数一样。
树形菜单的例子:
type MenuItem = { id: string; label: string; icon?: string; children?: MenuItem[]; // 递归:children也是MenuItem数组 }; const menu: MenuItem = { id: '1', label: '系统设置', children: [ { id: '1-1', label: '用户管理', children: [ { id: '1-1-1', label: '添加用户' }, { id: '1-1-2', label: '用户列表' } ] }, { id: '1-2', label: '权限管理' } ] };评论回复的例子:
type Comment = { id: string; content: string; author: string; replies?: Comment[]; // 评论下面可以有回复,回复下面还能有回复 }; const comments: Comment = { id: '1', content: '这篇文章写得不错', author: 'User1', replies: [ { id: '2', content: '确实', author: 'User2', replies: [ { id: '3', content: '+1', author: 'User3' } ] } ] };上个月做组织架构树的时候,用递归类型定义,无论多少层级都能完美支持。
最后说说我的感受
以前我也觉得TypeScript麻烦——
"写个类型定义比写业务代码还费劲"
"编译报错一堆,改半天"
"有这时间我代码都写完了"
但现在回过头看,那些"麻烦"的类型定义,帮我避免了无数次线上事故。
上周重构一个老项目,把核心模块加了完善的类型定义,编译器直接帮我找出了23个潜在bug——全是"可能访问undefined"、"遗漏case处理"这种问题。
如果没有TypeScript,这些bug只能等用户遇到了再来报。
TypeScript的价值不是让你写得更快,而是让你睡得更安稳。
不用担心改了一个地方其他地方炸了,不用担心遗漏了某个状态处理,不用担心配置被人乱改。
尤其是大型项目、多人协作的时候,TypeScript就是你的安全网。
快速回顾
技术点 | 解决什么问题 | 记住这句话 |
|---|---|---|
Indexed Access Types | 类型定义到处复制 | 类型也能用方括号访问 |
Type Guards | any满天飞不安全 | 教TypeScript怎么识别类型 |
Exhaustive Checking | 忘记处理某个case | 让编译器强制你处理所有情况 |
satisfies | 既要检查又要保留字面量 | 满足类型但不变成那个类型 |
infer | 从类型中提取信息 | 让TypeScript帮你推导 |
Labeled Tuples | 元组参数容易搞混 | 给元组的每个位置加标签 |
Mapped Types | 批量生成类型 | 遍历key生成新类型 |
Template Literal Types | 强制命名规范 | 字符串也能有类型约束 |
Distributive Conditional Types | 过滤联合类型 | 自动拆开联合类型处理 |
as const & readonly | 防止配置被改 | 锁死数据保留字面量 |
Recursive Types | 处理嵌套结构 | 类型定义里引用自己 |
你的项目里用到这些技巧了吗?或者遇到过哪些本可以用TypeScript避免的坑?评论区聊聊。
觉得有用的话,点个赞、转给你的同事,让更多人少踩坑、少加班。
关注「前端达人」,每周分享实用的前端开发经验,帮你写出更好维护、更少bug的代码。
下期见!👋