文章

类型驱动开发:API 响应类型与类型安全

面试向讲清类型驱动开发:用判别联合让不可能的状态写不出来、用 never 做穷尽检查、unknown + 类型守卫守住外部数据边界、 品牌类型防止 ID 串用、as const + satisfies 反向推导类型,以及"类型不是运行时校验"这条不可逾越的红线。

类型驱动开发:API 响应类型与类型安全

一句话概括

类型驱动开发就一句话:先定契约,再写逻辑。拿到一个需求,先把”数据长什么样”用类型写出来,再去写处理它的代码——你会发现很多 bug 在动手之前就被类型系统挡掉了。

面试问这个题,本质是在问你:TypeScript 除了给变量加 : string,还能干嘛? 答案是它能帮你把业务规则编码进类型里,让违反规则的代码根本编译不过。

核心就五招,按重要性排:

  1. 判别联合——让”不可能的状态”写不出来
  2. never 穷尽检查——后端加了个枚举值,你这边编译期就报警
  3. unknown + 类型守卫——外部数据进入系统前必须”过安检”
  4. 品牌类型——防止 userId 和 orderId 串用
  5. 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. 落地顺序建议

真要在项目里推,按这个顺序来,阻力最小:

  1. 打开 strict(尤其 strictNullChecks),这是所有招数的前提。
  2. 把所有异步状态改成判别联合 + assertNever 兜底。
  3. fetch / localStorage / URL 参数的返回值统一标 unknown,入口处加守卫或 schema 校验。
  4. 常量表改成 as const satisfies。
  5. 全局搜 as 和 any,逐个消灭(as 允许存在的场景:品牌类型转换、编译器已知的窄化)。
  6. 有条件就接 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 让类型从值反推不再漂移——但时刻记住类型编译后就没了,系统边界必须配运行时校验。

本文由作者按照 CC BY 4.0 进行授权