news 2026/4/14 22:10:27

那个说“TypeScript是多余的“的同事,昨晚又在改bug到凌晨

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
那个说“TypeScript是多余的“的同事,昨晚又在改bug到凌晨

先说个真事

上个月组里来了个新人,工作两年,简历上写着"精通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' };

现在有两个需求:

  1. 要类型检查:确保所有value都是string

  2. 要保留具体值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>; // 自动推导出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>为例):

  1. TypeScript看到T extends Promise<infer R>

  2. 发现T确实是Promise<...>的形式

  3. 推导出里面的类型是string,把它赋给R

  4. 返回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>; // 自动生成了!

怎么理解?

  1. keyof T拿到所有key:'username' | 'email' | 'password' | 'age'

  2. [K in keyof T]遍历每个key

  3. T[K]拿到这个key对应的类型

  4. 组装成(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

需求场景

有个联合类型,想过滤掉nullundefined

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 constreadonly

血泪教训

以前有个全局配置:

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的代码。

下期见!👋

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!