项目全面切 TypeScript 也有两年多了,一开始觉得不就是加个类型嘛,能有多难,结果这两年陆陆续续被几个”看起来很合理但实际上是坑”的地方教育过,挑几个印象深的写写。

最坑的还是 any 的传染性。团队里但凡有人图省事写了一个 any,这玩意儿会顺着调用链一路往下传,类型系统直接失效但编辑器不会提前警告你:

1
2
3
4
5
6
function parseConfig(raw: any) {
return raw.settings.theme // 这里怎么写都不会报错
}

const config = parseConfig(fetchRawConfig())
config.whatever.nonExistentField.toUpperCase() // 照样编译通过,运行时才炸

后来在 tsconfig.json 里开了 noImplicitAny 加上 eslint@typescript-eslint/no-explicit-any 规则强制拦,团队里想用 any 就得手写 // eslint-disable-next-line 加一行理由,麻烦到大部分人宁愿多花两分钟把类型写对,这个招数比口头约定管用多了。真遇到类型确实不知道的场景,用 unknown 替代 any,好歹逼着你在用之前做一次类型收窄。

第二个是接口返回的数据结构跟前端定义的 interface 对不上,这种坑真的防不胜防,因为 TypeScript 的类型检查是编译时的,后端接口实际返回什么它根本管不着:

1
2
3
4
5
6
7
8
interface User {
id: number
name: string
age: number
}

const res = await fetch("/api/user").then(r => r.json()) as User
console.log(res.age.toFixed(2)) // 后端某次改字段把 age 删了,运行时直接报错

那个 as User 纯粹是自我安慰,运行时该炸还是炸。后来对外部接口返回的数据统一上了 zod 做运行时校验,类型定义直接从 schema 推导出来,不用手写两遍还容易对不上:

1
2
3
4
5
6
7
8
9
10
11
12
import { z } from "zod"

const UserSchema = z.object({
id: z.number(),
name: z.string(),
age: z.number(),
})

type User = z.infer<typeof UserSchema>

const res = await fetch("/api/user").then(r => r.json())
const user = UserSchema.parse(res) // 字段对不上这里就直接抛错,不会拖到后面

这个改动之后接口字段变更导致的线上报错基本消失了,出问题也是第一时间在解析那一步就报出来,不会绕到什么奇怪的地方才崩。

泛型这块用多了也发现一个容易犯的错,就是过度设计。见过有人把一个只在两个地方用到的函数写成三层泛型嵌套,参数类型靠泛型推导来推导去,别人接手的时候光看类型签名就得盯着看五分钟,实际上用普通的联合类型两行就能写清楚。类型系统是用来帮人省心的,不是拿来炫技的,写之前多想想接手的人看不看得懂。

Enum 这个东西现在团队基本达成共识不用了,改用 as const 加联合类型:

1
2
3
4
5
6
7
const Status = {
Pending: "pending",
Active: "active",
Closed: "closed",
} as const

type Status = typeof Status[keyof typeof Status]

原因是 TypeScript 的数字 enum 编译出来的产物有点反直觉(数字枚举会生成双向映射,字符串枚举倒还好),而且 enum 是 TS 特有的语法,跟纯 JS 生态的类型推导有些摩擦,as const 这套写法既能拿到差不多的效果,产物也更干净,跟纯 JS 互操作没有额外心智负担。

写了这么久最大的体会是 TypeScript 能防住的是”手滑”和”接口字段忘了改”这类问题,防不住的是”逻辑本身就是错的”,类型检查过了不代表程序就是对的,该写的单测一个都不能少,把类型系统当成万能保险就是想多了。