
上个月组里来了个新人,工作两年,简历上写着"精通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); // 如果是错误信息,这里就炸了
后来我学会了先"检查"一下:
// 定义一个检查函数
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的属性"这种问题。
写一个订单状态处理函数:
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。
加一行代码就能避免:
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'.
为什么有用?
default 的时候,status 的类型就是 never(永远不会走到这里)default,类型对不上就报错用人话说:你在 default 放了个"绝对不会执行"的代码,如果真的执行了,说明你漏了东西。
我现在写枚举处理都会加这一行,救了我好多次。
satisfies做主题系统的时候,有个配置对象:
const theme = {
primary: '#1890ff',
success: '#52c41a',
error: '#ff4d4f'
};
现在有两个需求:
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了,不能改了
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>; // 自动推导出OrderList
infer 是什么意思?
简单说就是:"我不知道这里是什么类型,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> 为例):
T extends Promise<infer R>T 确实是 Promise<...> 的形式string,把它赋给 RR(也就是 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]?
// 鬼知道
}
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; // 一目了然
}
好处:
上个月重构地图系统的时候,把所有坐标类型都加了标签,新人上手快了很多。
做表单系统,每个字段都需要验证函数:
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个字段写到吐血。
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方法的类型定义,爽歪歪。
组里规定:所有事件处理函数必须以 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%**。
有个联合类型,想过滤掉 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,再也不担心被人误改了。
要定义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的代码。
下期见!👋