我接手过一个用了两年 TypeScript 的项目,打开一看却像在看穿了西装的 JavaScript:函数参数标着 any,接口字段写着 data: any,后端返回的对象直接 as 强转成期望的类型。问起来,团队的回答出奇一致:"TS 太啰嗦了,加 any 编译就过了。"可这套"穿着 TS 外衣的 JS",该出的运行时错误一个没少——传错参数顺序、读了不存在的字段、把没校验的字符串当成合法值用,全都堂而皇之地溜到了生产。
那次重构让我想清楚一件事:TypeScript 的价值从来不是"给变量贴个标签",而是用类型把"非法的状态变得根本无法表示"(make illegal states unrepresentable)。类型系统是一台在编译期就帮你跑遍所有分支的机器,你把约束写进类型,它就替你把违反约束的代码挡在合并之前。这篇不讲语法手册,而是把我这些年真正用来"消灭一整类 bug"的几件类型武器逐个摆出来:每件武器对应一个具体场景——它解决什么问题、怎么写、为什么比 any 强。
先认清:any、unknown 和具体类型的分水岭
动手之前先厘清最基础也最常被搞混的一点:any 和 unknown 看着像,实则是两个极端。any 是"关掉类型检查"——它会像病毒一样传染,一个 any 流过的地方,后面全成了无类型的裸奔;unknown 则是"我暂时不知道类型,但你用之前必须先收窄它",它把检查的责任牢牢钉在使用处。这张表是我贴在团队 wiki 上的判断基准:
| 选择 | 含义 | 类型检查 | 适用场景 |
|---|---|---|---|
any |
放弃检查 | 全关,会传染 | 几乎永远不该用(除非临时迁移过渡) |
unknown |
未知但安全 | 用前强制收窄 | 外部输入:接口响应、JSON.parse、第三方数据 |
| 具体类型 | 明确约束 | 全程检查 | 绝大多数业务代码 |
| 联合类型 | 有限可能 | 按分支收窄 | 状态、枚举值、多形态数据 |
一句话记牢:边界处(外部数据进来的那一刻)用 unknown 接住、就地校验收窄,内部业务代码全程用具体类型,any 只在迁移老代码的临时过渡期出现、且必须挂 TODO。把这条立成规矩,类型系统才算真正开机。
武器一:判别联合,让非法状态无法表示
最高频、也最能体现"类型设计"威力的场景,是表达一个有多种形态的状态。拿一个再常见不过的异步请求状态来说,很多人是这么写的:
// ❌ 松散对象:四个字段彼此独立,编译器拦不住任何非法组合
interface RequestState<T> {
loading: boolean;
data: T | null;
error: Error | null;
success: boolean;
}
// 于是下面这些自相矛盾的状态全都"合法",全靠人脑记着别写错:
const a: RequestState<User> = { loading: true, data: someUser, error: null, success: true }; // 又在 loading 又 success?
const b: RequestState<User> = { loading: false, data: null, error: null, success: true }; // success 了却没 data?
问题的根子在于:这四个字段是正交的,编译器眼里 2×2×2×2 种组合都合法,可业务上真正合法的只有四种(初始、加载中、成功、失败)。修正的关键是用一个共同的"判别字段"(这里是 status)把它们拧成一个判别联合(discriminated union),让每种状态只携带它该有的字段:
// ✅ 判别联合:每个分支只带它合法的字段,非法组合根本无法被构造出来
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // 只有 success 才有 data
| { status: 'error'; error: Error }; // 只有 error 才有 error
function render(state: RequestState<User>) {
switch (state.status) {
case 'idle': return '点击加载';
case 'loading': return '加载中…';
case 'success': return state.data.name; // ✅ 此分支里 data 一定存在,无需判空
case 'error': return state.error.message;// ✅ 此分支里 error 一定存在
}
}
这一改有三个立竿见影的好处:其一,{ loading: true, success: true } 这种自相矛盾的状态从类型层面就构造不出来;其二,在 success 分支里访问 state.data 不再需要 ?. 判空——编译器已经知道它一定存在;其三,配合下面要讲的"穷尽检查",一旦你将来加了第五种状态却忘了处理,编译器会直接报错。状态机、表单、消息协议、订单流转……凡是"有限多种形态"的数据,判别联合都是首选武器。
那么编译器是如何在每个 case 里精确知道"此刻是哪个分支、有哪些字段"的?靠的是类型收窄(narrowing):它顺着 switch (state.status) 这个判别字段,在每个分支里把联合类型自动缩小到对应的那一支。下面这张图就是它的收窄过程:
武器二:never 穷尽检查,逼编译器替你查"漏处理"
判别联合最迷人的搭档是 never 类型带来的穷尽性检查(exhaustiveness check)。原理是:当所有分支都被处理完后,剩下的类型会被收窄成 never(空类型)。我们故意在 default 里把它赋给一个 never 变量——只要将来有人加了新状态却忘了在 switch 里处理,这个赋值就会因为"非空类型不能赋给 never"而编译报错,把"漏改一处"这种典型疏忽,从线上 bug 提前成了编译错误:
function assertNever(x: never): never {
throw new Error(`未处理的分支: ${JSON.stringify(x)}`);
}
function render(state: RequestState<User>): string {
switch (state.status) {
case 'idle': return '点击加载';
case 'loading': return '加载中…';
case 'success': return state.data.name;
case 'error': return state.error.message;
default: return assertNever(state); // ✅ 漏了某个 case,这里就编译不过
}
}
// 将来若给 RequestState 加上 { status: 'timeout' } 而忘了在上面加 case,
// state 在 default 处会是 { status: 'timeout' } 而非 never → 编译器立刻报错
这就是类型设计的精髓:不是写完代码靠测试去发现遗漏,而是让"遗漏"本身在编译期就无法通过。每加一个枚举值、每扩一种消息类型,编译器都会主动把所有该改的地方给你列出来——重构大型联合类型时,这一招能省下海量的人肉排查。
武器三:品牌类型,让 UserId 和 OrderId 不再混用
第三个场景藏得很深却极其高发:一堆"底层都是 string / number"的值,在类型上却完全等价,于是编译器拦不住你把 orderId 传进一个本该收 userId 的函数。这类 bug 编译全过、运行时却查错了数据,排查起来格外费劲。品牌类型(branded types)用一个"假"的标记字段给它们打上互不兼容的烙印:
// ✅ 给原始类型打上互不兼容的"品牌",编译期区分本质同为 string 的不同 ID
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
// 只能通过校验函数"铸造"品牌值,从源头保证它确实合法
function toEmail(raw: string): Email {
if (!/^[^@]+@[^@]+\.[^@]+$/.test(raw)) throw new Error('非法邮箱');
return raw as Email; // 校验通过后才贴牌,as 收口在这一处
}
function sendMail(to: Email) { /* ... */ }
function getUser(id: UserId) { /* ... */ }
const oid = 'o_123' as OrderId;
// getUser(oid); // ❌ 编译报错:OrderId 不能赋给 UserId —— 混用被当场拦下
// sendMail('hi'); // ❌ 编译报错:裸 string 不是 Email —— 必须先经 toEmail 校验
sendMail(toEmail('a@b.com')); // ✅ 唯一合法路径:校验 → 贴牌 → 使用
品牌类型的妙处在于:它零运行时开销(那个 __brand 字段根本不存在于运行时,纯是给编译器看的),却能把"这个 string 是否已经过校验""这个 id 到底是谁的 id"这类语义,实打实地编码进类型里。从此"未校验的输入"和"已校验的合法值"在类型上就是两种东西,想跳过校验直接用?编译器第一个不答应。
武器四:as const 与 satisfies,精确推断和类型约束兼得
第四件武器解决一个微妙的两难:你既想让一个配置对象被精确推断(字面量而非宽泛的 string),又想约束它符合某个结构。老办法 const config: Config = {...} 会把推断拓宽、丢掉字面量信息;TS 4.9 起的 satisfies 则鱼和熊掌兼得:
type RouteConfig = Record<string, { path: string; auth: boolean }>;
// ❌ 注解写法:类型被拓宽成 RouteConfig,routes.home.path 退化成 string,key 也不精确
const routesA: RouteConfig = {
home: { path: '/', auth: false },
profile: { path: '/profile', auth: true },
};
// ✅ satisfies:既校验结构符合 RouteConfig,又保留每个字面量的精确类型
const routes = {
home: { path: '/', auth: false },
profile: { path: '/profile', auth: true },
} satisfies RouteConfig;
type RouteName = keyof typeof routes; // ✅ 精确为 'home' | 'profile',而非 string
const p = routes.home.path; // ✅ 类型是 '/' 这个字面量,不是宽泛 string
// as const 则把整个对象冻成只读字面量,常用来从数据反推出联合类型
const STATUSES = ['idle', 'loading', 'success', 'error'] as const;
type Status = typeof STATUSES[number]; // ✅ 'idle' | 'loading' | 'success' | 'error'
这一对组合拳的价值在于让数据成为类型的唯一真相来源:你写一份配置或一个常量数组,类型自动从数据里"长"出来,再不用手动维护一份和数据同步的类型声明——数据一改,类型跟着变,二者永远不会对不上。satisfies 负责"校验但不拓宽",as const 负责"冻结成精确字面量",二者是现代 TS 里用得最顺手的两个小工具。
武器五:类型守卫与断言函数,安全地驯服 unknown
回到开头那张表里的关键一格:外部数据(接口响应、JSON.parse、localStorage)进来时,正确的接法是 unknown 而非 any。但 unknown 用之前必须收窄——这就轮到类型守卫(type guard)和断言函数(assertion function)出场了。它们的返回类型里带有 x is T / asserts x is T 这种"类型谓词",能把收窄的结果告诉编译器:
interface User { id: string; name: string; age: number; }
// 类型守卫:返回 x is User,调用后编译器就知道 x 是 User
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& typeof (x as any).id === 'string'
&& typeof (x as any).name === 'string'
&& typeof (x as any).age === 'number';
}
// ✅ 边界处:fetch 回来的是 unknown,先校验再使用,绝不裸 as
async function loadUser(): Promise {
const raw: unknown = await fetch('/api/user').then(r => r.json());
if (!isUser(raw)) throw new Error('接口返回结构非法');
return raw; // ✅ 此处 raw 已被收窄为 User
}
// 断言函数:校验不过直接抛,通过后把类型"钉"成 User,后续无需再判
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('not a User');
}
function greet(x: unknown) {
assertUser(x);
console.log(x.name); // ✅ 断言之后,x 的类型已是 User
}
这里的核心纪律是:所有跨越系统边界进来的数据都默认不可信,一律先校验、后使用,而不是用 as 一拍脑袋强转。as 是"我以人格担保它是这个类型"的强断言,编译器会闭眼信你——这恰恰是 bug 的温床。生产项目里更稳的做法是用 zod / valibot 这类运行时校验库,在边界处一次性完成"校验 + 推断类型",省去手写守卫的繁琐。但无论用什么,原则不变:边界处校验,内部信类型。
武器六:泛型约束与工具类型,把重复的类型逻辑抽出来
当类型逻辑开始重复,就该请出泛型约束和内置工具类型了。泛型让函数对类型"参数化",而 extends 约束则给这个类型参数划定范围,既保留灵活性、又不至于松到失去检查:
// 泛型约束:K 必须是 T 的键,既灵活又类型安全的"按 key 取值"
function pick(obj: T, keys: K[]): Pick {
const result = {} as Pick;
for (const k of keys) result[k] = obj[k];
return result;
}
const user = { id: '1', name: 'Ann', age: 20, token: 'secret' };
const safe = pick(user, ['id', 'name']); // ✅ 类型精确为 { id: string; name: string }
// pick(user, ['xxx']); // ❌ 'xxx' 不是 user 的键,编译报错
// 善用内置工具类型,别手写重复结构:
type UserPatch = Partial; // 全字段可选 —— 更新接口的入参
type UserView = Omit; // 排掉敏感字段 —— 对外返回
type UserMap = Record; // id → User 的字典
type Keys = keyof User; // 'id' | 'name' | 'age'
Partial / Required / Pick / Omit / Record / Readonly 这套内置工具类型,覆盖了日常八成的类型变换需求。一个值得养成的习惯是:同一份数据的不同形态(完整实体、更新入参、对外视图、表单草稿),都从一个"源类型"用工具类型派生出来,而不是各写一遍。源类型一改,所有派生类型自动跟着变,彻底告别"改了实体忘了改 DTO"的低级不一致。
武器七:模板字面量类型,连字符串拼接都能类型安全
最后一件常被低估的武器是模板字面量类型(template literal types)。它能在类型层面做字符串拼接和模式匹配,把"事件名""路由路径""CSS 变量名"这类靠约定拼出来的字符串,也纳入编译器的检查范围:
// 在类型层面拼字符串:从实体字段自动派生出事件名,不再手写易错的魔法字符串
type Entity = 'user' | 'order';
type Action = 'created' | 'updated' | 'deleted';
type EventName = `${Entity}:${Action}`;
// ✅ 自动展开为 'user:created' | 'user:updated' | ... | 'order:deleted' 共 6 种
function on(event: EventName, handler: () => void) { /* ... */ }
on('user:created', () => {}); // ✅ 合法
// on('user:login', () => {}); // ❌ 'user:login' 不在联合里,编译报错,拼错当场暴露
// 还能反向"提取":从带参数的路径里把参数名解析成对象类型
type ExtractParams =
S extends `${string}:${infer P}/${infer Rest}` ? { [K in P]: string } & ExtractParams<`/${Rest}`>
: S extends `${string}:${infer P}` ? { [K in P]: string }
: {};
type Params = ExtractParams<'/users/:userId/orders/:orderId'>;
// ✅ 推导出 { userId: string; orderId: string } —— 路由参数也有了类型保障
模板字面量类型把 TS 的类型系统推进到了"能对字符串做计算"的程度。它最实用的落点是消灭那些散落各处、靠手写约定维护的魔法字符串:事件总线的事件名、状态机的转移名、国际化的 key、CSS-in-JS 的变量名——以前拼错了只能等运行时报错甚至静默失效,现在拼错的那一刻编译器就红给你看。
遇到一个类型场景,该抄起哪件武器?
七件武器摆完,实战里更需要的是一棵"看到什么场景、该上什么类型手段"的决策树。下次设计一个类型时,先在脑子里过一遍它,绝大多数选择当场就有了答案:
沉淀成清单的几条类型铁律
这套实践收口成了我们前端的 TypeScript 编码规范,配 tsconfig 的 strict 全开和 ESLint 的 no-explicit-any 规则在 CI 里强制校验:
- strict 模式一开始就全开:
strict: true含strictNullChecks等一揽子检查,项目越老越难补,新项目第一天就开。 - 禁用 any,边界用 unknown:
any会传染、等于关检查;外部数据一律unknown接住、校验后再用,迁移期的any必须挂 TODO。 - 让非法状态无法表示:多形态数据用判别联合,而非一堆正交的布尔/可空字段,从源头杜绝自相矛盾的状态。
- 联合类型配 never 穷尽检查:
switch的default走assertNever,漏处理新分支时编译期就报错。 - 语义不同的原始值用品牌类型:
UserId/OrderId/ 已校验的Email互不兼容,只能经校验函数铸造。 - 类型从数据派生,不手动同步:
as const+satisfies+ 工具类型,让数据成为类型的唯一真相来源。 - as 与 ! 是最后手段:每一次类型断言都是关掉一处检查;能用类型守卫收窄就别用
as,实在要用就把它收口在一处并写清理由。
几个反复见到的认知误区
推广这套规范时,我发现有几个误区几乎人人都踩,值得专门点破。
第一个误区是把 any 当成"快速通关"的钥匙。"加个 any 编译就过了"——这是最常见也最有害的习惯。any 不是"跳过这一个检查",而是"让类型检查从这里开始失效":它会顺着数据流一路传染,凡是它流经的地方,TS 就退化回了 JS。真正该接外部不确定数据的是 unknown,它强迫你在使用前先收窄,把"不确定"老老实实处理掉,而不是假装它不存在。any 多一个,你的类型安全网就破一个洞。
第二个误区是把 as 当类型转换用。很多人以为 x as User 是"把 x 转成 User",其实它是"我向编译器担保 x 就是 User,你别查了"。它不做任何运行时转换或校验,纯粹是关掉编译器的一次质疑。所以拿接口返回 as 成期望类型,是最危险的操作之一:一旦后端字段变了,类型层面毫无察觉,错误一路滑到运行时。能用类型守卫、能用校验库,就别用 as。
第三个误区是"类型写得越花越高级"。见过有人为一个简单结构套上三层条件类型、嵌套映射类型,炫技炫到没人看得懂、报错信息长到看不到头。类型设计的目标是降低出错概率和心智负担,不是展示类型体操功力。判别联合、品牌类型、工具类型这些朴素武器已经能覆盖绝大多数场景;真到要写复杂条件类型时,先问自己:这复杂度是业务本身要求的,还是我自找的?能用简单类型表达清楚,就是最好的类型。
第四个误区是"类型对了就等于没 bug"。类型系统只能保证"形状对",保证不了"值对":它拦得住你把 OrderId 传给 UserId,却拦不住你的业务逻辑算错了金额。而且 TS 的类型在编译后完全擦除,运行时一行都不剩——这正是为什么边界处仍然需要运行时校验(zod 那类),光有编译期类型,挡不住一个结构不符的接口响应。类型安全是地基,不是全部,该写的单元测试、该做的运行时校验,一样都不能省。
怎么把这套东西落进一个 any 满天飞的老项目
道理讲完,最现实的问题往往是:我手上这个项目已经 any 满天飞了,总不能停下所有需求,花一个月把类型全补一遍——那不现实,业务也不会答应。我当初接手开头那个项目时,用的是一套"渐进收紧"的打法,几个月里悄无声息地把它从裸奔状态拉回了类型安全,关键是每一步都不阻塞日常开发。
第一步是先止血,不追溯:打开 ESLint 的 no-explicit-any 规则,但只设为 warn 而非 error,并且只对"新增和改动的代码"生效(配合 lint-staged 只检查暂存区文件)。这样存量的 any 不会瞬间炸出几千个错误把人吓退,但新写的代码再想偷懒加 any 就会被提醒。先把"出血点"摁住,不让债越欠越多,这一步几乎零成本却最关键。
第二步是从边界往里收。类型债最该先还的地方,永远是数据进出系统的边界——接口层。我们优先给所有接口响应补上类型定义和运行时校验(用 zod 在请求层一次性搞定校验加推断),因为边界处的 any 危害最大、收益也最高:一处接口类型补全,顺着数据流能让下游一大片代码自动获得类型。把边界守住之后,内部代码即便还有些松,至少"脏数据进不来"这条底线先立住了。
第三步是strict 选项逐个开,而不是一把全开。strict 其实是一组开关的总和,可以拆开来逐个启用:先开影响最大、报错最可控的 noImplicitAny 和 strictNullChecks,每开一个就集中修一批,修干净了再开下一个。这样把一次"几千个错误"的休克式改造,拆成了若干次"几十个错误"的小步迭代,每一步都能在一两天内收尾,既看得到进度,也不会把团队拖垮。等所有子选项都单独开过、修过,最后把 strict: true 正式打开时,反而一个错误都不会再冒出来——因为债早已经在前面几步里悄悄还清了。
写在最后
梳理完这七件武器,我最大的体会是:TypeScript 用得好不好,分水岭不在于你认识多少类型语法,而在于你是否把类型当成"设计工具"而非"事后注解"。那些把 TS 写成"JS + any"的团队,不是不会写类型,而是没意识到类型可以前置到设计阶段去消灭一整类 bug——把非法状态设计成无法表示,把未校验的值设计成无法直接使用,把拼错的字符串设计成无法通过编译。bug 不是被测出来的,是被类型挡在门外、压根没机会发生的。
所以别再问"这个变量要不要标类型"了,该问的是"我能不能用类型,让这个错误根本写不出来"。判别联合让矛盾状态无法构造,品牌类型让混用无法编译,穷尽检查让遗漏无法通过,模板字面量让拼错当场暴露——它们指向的是同一个理念:与其在运行时祈祷不出错,不如在编译期就让错误无处容身。把这个理念吃透,你写的就不再是"穿着 TS 外衣的 JS",而是真正让类型系统替你干活、把一整类错误扼杀在编译期的 TypeScript。
—— 别看了 · 2026