类型驱动开发:API 响应类型与类型安全
面试向讲清类型驱动开发:用判别联合让不可能的状态写不出来、用 never 做穷尽检查、unknown + 类型守卫守住外部数据边界、 品牌类型防止 ID 串用、as const + satisfies 反向推导类型,以及"类型不是运行时校验"这条不可逾越的红线。
一句话概括
类型驱动开发就一句话:先定契约,再写逻辑。拿到一个需求,先把”数据长什么样”用类型写出来,再去写处理它的代码——你会发现很多 bug 在动手之前就被类型系统挡掉了。
面试问这个题,本质是在问你:TypeScript 除了给变量加 : string,还能干嘛? 答案是它能帮你把业务规则编码进类型里,让违反规则的代码根本编译不过。
核心就五招,按重要性排:
- 判别联合——让”不可能的状态”写不出来
never穷尽检查——后端加了个枚举值,你这边编译期就报警unknown+ 类型守卫——外部数据进入系统前必须”过安检”- 品牌类型——防止
userId和orderId串用 as const+satisfies——从值反推类型,消灭重复定义
核心知识点
1. 从”可选字段堆”到”判别联合”
先看反面教材,这个写法几乎每个项目都有:
1
2
3
4
5
6
7
8
9
// ❌ 典型烂设计:三个可选字段,8 种组合里只有 3 种是合法的
type RequestState = {
loading?: boolean;
data?: User;
error?: Error;
};
// 下面这个"既在加载、又有数据、还有错误"的对象,类型完全合法
const nonsense: RequestState = { loading: true, data: user, error: new Error() };
问题在哪?类型允许了大量逻辑上不可能存在的状态,于是你代码里到处是 if (data && !loading && !error),还总有漏判。
判别联合(Discriminated Union)就是解药:用一个字面量类型的公共字段做”标签”,把互斥的状态拆成互不相容的成员。
1
2
3
4
5
6
// ✅ 四个成员互斥,不可能同时成立
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
关键在 status 用的是字面量类型('idle' 而不是 string)。TS 看到 state.status === 'success' 就会把类型收窄到那一个成员,然后 state.data 才能访问:
1
2
3
4
5
6
7
8
9
10
11
12
function render(state: RequestState<User>) {
// ❌ 不判断直接访问 → 报错:Property 'data' does not exist
// state.data;
if (state.status === 'success') {
return state.data.name; // ✅ 这里 state 已被收窄成 success 成员
}
if (state.status === 'error') {
return state.error.message;
}
return '加载中…';
}
建模要点(容易答错):
- 判别字段必须只有一个,别搞
kind+type双标签,会互相打架。 - 判别字段必须是字面量联合,写成
string就退化成普通联合,收窄失效。 - 别在联合里塞可选字段。
{ status: 'success'; data?: T }等于把可选项又请回来了,白拆。
2. never 穷尽检查:把”漏改”变成编译错误
判别联合最值钱的附加能力:穷尽性检查。
1
2
3
4
5
6
7
8
9
10
11
12
13
function assertNever(x: never): never {
throw new Error(`未处理的分支: ${JSON.stringify(x)}`);
}
function handle(result: PaymentResult) {
switch (result.type) {
case 'success': return result.transactionId;
case 'failed': return result.error;
case 'pending': return result.estimatedTime;
default:
return assertNever(result); // 兜底
}
}
原理:default 分支里 result 的类型应该已经被前面所有 case 收窄光了,所以它是 never。只有当所有分支都处理完时,这里才能通过类型检查。
于是——后端哪天加了个 refunded 状态而你没改前端代码,result 在 default 里就是 { type: 'refunded' },不能赋给 never,编译直接报错。这就是”类型驱动”最实在的收益:把运行时的线上事故提前到 tsc 阶段。
注意:这个检查只在 switch 有返回值(或开启了相关 lint)时真正发挥作用。如果 case 里只是 break 不 return,漏分支不会被抓住——所以推荐让 switch 返回值。
3. unknown 而不是 any:外部数据的安检口
any 和 unknown 都能接住任意值,区别只有一句:
any是”我放弃检查”(对这个值做任何操作都合法);unknown是”我还不知道”(不收窄就不能碰)。
所有跨系统边界进来的数据——fetch 响应、localStorage、URL 参数、postMessage、第三方 SDK 回调——都应该先落进 unknown,再收窄:
1
2
3
4
5
6
7
8
9
// ❌ 自欺欺人:TS 信了你的谎,运行时该崩还是崩
const user = (await res.json()) as User;
console.log(user.profile.name); // 后端改了结构 → undefined 报错
// ✅ 先 unknown,再守卫
const raw: unknown = await res.json();
if (isUser(raw)) {
console.log(raw.profile.name); // 收窄后才安全
}
顺带说一句 as 的本质:as 是告诉编译器”闭嘴”,它不做任何运行时检查。项目里满地 as 等于把 TS 关掉了一半,是类型驱动开发最大的敌人。
4. 类型守卫 vs 断言函数:两个工具,两种场景
类型守卫(predicate):返回 boolean,你在 if 里分支。
1
2
3
4
5
6
7
8
9
function isUser(value: unknown): value is User {
return (
typeof value === 'object' && value !== null &&
'id' in value && typeof (value as User).id === 'string'
);
}
// 用起来:过滤数组自动变成窄类型(这个用法很多人不知道)
const users = items.filter(isUser); // User[],不是 (User | unknown)[]
断言函数(assertion):返回 asserts value is T,不返回布尔值——失败就抛异常,成功后后续作用域全部收窄。
1
2
3
4
5
6
7
8
9
10
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new TypeError('响应格式非法');
}
async function loadUser(id: string) {
const raw: unknown = await (await fetch(`/api/users/${id}`)).json();
assertIsUser(raw);
// 从这里往下,raw 就是 User,不用再写任何 if
return raw.name;
}
怎么选?一张表:
| 工具 | 语法 | 失败时 | 适用场景 |
|---|---|---|---|
typeof / in / 真值判断 | 内置 | 走 else 分支 | 简单判断 |
| 类型守卫 | x is T | 返回 false | 需要分支处理、数组过滤 |
| 断言函数 | asserts x is T | 抛异常 | 边界校验,失败就该炸 |
易错点:asserts 函数必须显式标注返回类型,且被断言的变量不能是 const 之外无法收窄的形式;另外它只对同一作用域内、断言之后的代码生效。
5. 品牌类型:让 structurally 相同的类型不可互换
TS 是结构化类型系统,所以这个 bug 编译器抓不到:
1
2
3
4
5
function getUser(id: string) { /* ... */ }
function getOrder(id: string) { /* ... */ }
const orderId = 'ord_123';
getUser(orderId); // ✅ 编译通过,但这是彻头彻尾的 bug
两个都是 string,结构相同即可互相赋值。品牌类型(Branded Type)就是给类型打一个只在类型层面存在、运行时不存在的”假属性”,人为制造名义类型:
1
2
3
4
5
6
7
8
9
10
11
12
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function getUser(id: UserId) { /* ... */ }
const uid = 'usr_1' as UserId; // 唯一入口:显式转换(通常在解析边界做一次)
const oid = 'ord_1' as OrderId;
getUser(uid); // ✅
getUser(oid); // ❌ 编译报错:OrderId 不能赋给 UserId
要点:
__brand属性运行时不存在,它是纯粹的编译期标记,零运行时开销。- 转换只应该在数据入口做一次(解析响应、读 URL 参数时),业务代码里不该再出现
as UserId。 - 更严格的写法用
unique symbol做品牌,防止不同品牌互相兼容:declare const brand: unique symbol。
这条是”类型驱动”里的进阶招,讲出来能明显拉开差距。
6. as const + satisfies:从值反推类型,一份数据源
很多时候类型和数据是重复的——你写了个常量对象,又手写了一遍它的联合类型,两边很快就会不同步。
as const:把字面量冻住,让 TS 推导出最精确的类型。
1
2
3
4
const ROLES = ['admin', 'member', 'viewer'] as const;
type Role = (typeof ROLES)[number]; // 'admin' | 'member' | 'viewer'
const role: Role = 'superadmin'; // ❌ 报错
不加 as const 的话 ROLES 是 string[],typeof ROLES[number] 就只能是 string,白搭。
satisfies(TS 4.9+):校验一个值符合某类型,同时保留最精确的推导类型。这是它跟冒号注解的关键区别。
1
2
3
4
5
6
7
8
9
10
type Palette = Record<string, string | number[]>;
// ❌ 冒号注解:类型"压过"值,具体类型被抹平
const p1: Palette = { primary: '#3178c6', accent: [49, 120, 198] };
p1.primary.toUpperCase(); // ❌ 报错:primary 是 string | number[]
// ✅ satisfies:值"压过"类型,既校验又保留具体类型
const p2 = { primary: '#3178c6', accent: [49, 120, 198] } satisfies Palette;
p2.primary.toUpperCase(); // ✅ primary 就是 '#3178c6'
p2.accent.map(v => v * 2); // ✅ accent 就是 number[]
一句话记忆:冒号是”类型赢”,satisfies 是”值赢”。想校验又不丢精度就用 satisfies。
组合拳 as const satisfies T 是最强形态——既冻结字面量又做校验,配置表、路由表、枚举映射表都该这么写:
1
2
3
4
5
6
7
const STATUS_LABEL = {
pending: '待审核',
approved: '已通过',
rejected: '已驳回',
} as const satisfies Record<Status, string>;
// 补:Status 新增一个值时,上面漏写就直接编译报错
7. 类型不是运行时:校验边界在哪
这是必须讲清楚的认知红线,也是面试官最想听到的成熟判断。
TS 类型在编译后全部消失。所以:
类型只能保证”你自己写的代码”内部一致,保证不了外部数据真的长这样。
1
2
// 类型写得再全,后端返回 { usr: {} } 你也拦不住
const data: ApiResp<User> = await res.json();
正确的分层:
| 位置 | 手段 | 说明 |
|---|---|---|
| 内部代码 | TS 类型 | 编译期约束,零成本 |
| 系统边界(HTTP、storage、URL、postMessage) | 运行时校验 | 类型在这里无能为力 |
| 契约同步 | 代码生成(OpenAPI → TS) | 避免手写类型和后端漂移 |
边界处的运行时校验,主流做法是 schema 库(Zod、Valibot 等)——写一份 schema,同时得到运行时校验器和 TS 类型,一次定义两处收益:
1
2
3
4
5
6
import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>; // 类型从 schema 反推,不会漂移
const user = UserSchema.parse(await res.json()); // 校验失败直接抛错
如果后端有 OpenAPI / GraphQL schema,优先用 openapi-typescript、graphql-codegen 之类的工具生成类型,别手写——手写必然漂移,而漂移的类型比没有类型更危险,因为它给你虚假的安全感。
(Zod 本身值得单独一篇,这里只讲它在”边界校验”这个位置的作用。)
8. 落地顺序建议
真要在项目里推,按这个顺序来,阻力最小:
- 打开
strict(尤其strictNullChecks),这是所有招数的前提。 - 把所有异步状态改成判别联合 +
assertNever兜底。 fetch/localStorage/ URL 参数的返回值统一标unknown,入口处加守卫或 schema 校验。- 常量表改成
as const satisfies。 - 全局搜
as和any,逐个消灭(as允许存在的场景:品牌类型转换、编译器已知的窄化)。 - 有条件就接 codegen,让类型跟着后端 schema 走。
其实你每天都在用
- 请求 hooks 的返回值:
{ status, data, error }判别联合,Vue 的useRequest、React Query 都是这个模型。 - Redux / Pinia 的 action:
{ type: 'ADD_TODO', payload }联合 +switch穷尽检查。 - 表单校验:校验结果用
{ ok: true, value } | { ok: false, errors }判别联合,比{ valid, errors? }清晰得多。 - 路由表 / 菜单配置:
as const satisfies之后,routes.home.path有字面量补全,写错路径直接报错。 - 主题色板、i18n 文案映射表:
satisfies Record<Locale, string>漏翻译就编译报错。 localStorage.getItem()拿到的东西:JSON.parse后先落unknown,守卫过一遍再用。- 金额/ID 这类高混淆风险的字段:品牌类型,防止
pay(userId)这种串参数事故。 - 后端 OpenAPI 生成的
api.d.ts:接口改了字段,前端编译期集体报错。
常见误解(FAQ)
❌ 误区1:”判别联合就是把可选字段写成联合,差不多。” 差很多。{ status: 'success'; data?: T } 这种在成员里带可选字段的写法等于白拆——又回到”字段可以任意组合”的老路。判别联合的要求是:每个成员自带且仅带该状态需要的字段,且判别字段是字面量类型。
❌ 误区2:”判别字段写成 string 也能收窄。” 不能。必须是字面量联合类型('success' | 'error')。写成 string 的话,state.status === 'success' 之后 TS 无法排除其他成员,收窄失效。
❌ 误区3:”as User 和类型守卫效果一样,只是写法短。” 完全不一样。as 是对编译器撒谎,不做任何检查,运行时该崩还是崩;类型守卫是真的去检查了字段,检查通过才收窄。项目里 as 泛滥 = TS 白装。
❌ 误区4:”unknown 和 any 都行,用 any 方便点。” any 会传染——any 参与的任何运算结果都是 any,一处 any 能让整条调用链的类型检查全部失效。unknown 强制你先收窄再使用,是安全的默认值。规则:不确定就写 unknown。
❌ 误区5:”断言函数就是返回 boolean 的类型守卫。” 不是。类型守卫返回 boolean(x is T),你在 if 里分支;断言函数返回 asserts x is T,失败直接抛异常,成功后当前作用域后续代码全部收窄。选哪个看”失败时该分支处理还是该直接崩”。
❌ 误区6:”品牌类型会在对象上加一个 __brand 属性,有运行时开销。” 不会。__brand 只存在于类型层面,编译产物里没有这个属性,零运行时成本。它只是骗过结构化类型系统的一个标记。
❌ 误区7:”satisfies 和冒号注解没什么区别。” 区别正是 satisfies 存在的意义:冒号注解会把类型”拓宽”(primary 从 '#3178c6' 变成 string),satisfies 保留最精确的推导类型。需要”既校验又保留字面量”时用 satisfies,需要”后续要重新赋值成联合里的其他值”时用冒号。
❌ 误区8:”我都上了 TypeScript,就不需要运行时校验了。” 类型在编译后全部消失。TS 只能保证你自己写的代码内部自洽,保证不了后端真的按契约返回。所有跨系统边界(HTTP、storage、URL、postMessage、第三方 SDK)都必须有运行时校验或 schema 解析。
❌ 误区9:”手写 API 类型就够了,不用接 codegen。” 手写类型必然跟后端漂移,而漂移的类型比没有类型更危险——它给你虚假的安全感,让你不去检查。有 OpenAPI / GraphQL schema 就上 codegen,没有就用 schema 库在运行时兜住。
❌ 误区10:”类型驱动就是类型写得越复杂越好。” 反了。类型驱动的目的是减少运行时分支和防御性代码,不是炫技。判断标准很简单:这个类型是让调用方更省心了(写错就报错),还是更费劲了(到处要 as 才能过编译)?后者说明类型设计走偏了。
一句话总结
类型驱动开发的核心是把业务规则编码进类型:判别联合让不可能的状态写不出来、never 兜底让后端新增分支时前端编译期报错、unknown + 类型守卫守住外部数据的安检口、品牌类型防止同构 ID 串用、as const satisfies 让类型从值反推不再漂移——但时刻记住类型编译后就没了,系统边界必须配运行时校验。