TypeScript 类型进阶:从泛型约束到类型体操的工程边界
本文目录
TypeScript 的价值不在于"把 JS 变难",而在于把接口契约从注释和口头约定升级成编译器可校验的东西。这篇文章按使用频率组织进阶内容:泛型约束与推断、条件类型与 infer、模板字面量类型、映射类型与 as 修饰、类型收窄、unknown 与 any 的边界、声明文件与类型扩展,最后讨论一个经常被跳过的问题——什么时候应该停止堆类型复杂度。所有结论都围绕一个前提:类型在编译后会被完全擦除,它们只服务两件事,编译期错误提示和编辑器补全。
类型系统解决的是什么问题
先明确一个事实:TypeScript 的类型检查发生在编译期,产物是不含任何类型的 JavaScript。这决定了所有类型技巧的定位——它们不改变运行时行为,只改变"错误在什么时候被发现":
| 不写类型 | 写类型 | 写复杂类型 |
|---|---|---|
| 错误在运行时暴露,可能到线上 | 大量低级错误在编辑器里就被标红 | 少数深层错误被提前发现,但阅读成本显著上升 |
| 重构靠全局搜索,漏改只能靠测 | 重构时编译器列出所有断点 | 重构时类型本身可能也要维护 |
| 任何人都能改 | 新人有类型提示兜底 | 只有作者理解复杂类型 |
这个表格是后面"工程边界"一节的伏笔:类型复杂度是一条收益递减的曲线,不是越高越好。
泛型:约束与推断
无约束泛型是失控的
function bad<T>(item: T): T {
return item.name; // 编译错误:T 上不存在 name
}
无约束的 T 意味着"任何类型",所以编译器只能假设它连 name 都没有。泛型约束就是把这个假设范围收窄到可用的程度。
extends 约束与 keyof
interface HasId {
id: string;
}
function findById<T extends HasId>(list: T[], id: string): T | undefined {
return list.find((item) => item.id === id);
}
T extends HasId 表示 T 至少有 id: string,函数体里可以安全访问,调用方传入的具体类型原样返回。keyof 是约束的常见搭档,用来保证"键必须来自这个对象":
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user = { id: 1, name: "a", email: "a@x.com" };
const slim = pick(user, ["id", "name"]); // 类型是 { id: number; name: string }
// pick(user, ["nickname"]); // 编译错误:nickname 不在 keyof 范围内
这是"库函数签名"的经典写法:约束保证函数体合法,返回值用 Pick<T, K> 精确还原调用处的选择。
让推断发生在调用处
泛型的价值在于调用方少写类型。原则是:约束写在泛型参数上,具体类型让编译器从实参推断。
function createPair<A, B extends string>(first: A, second: B): [A, B] {
return [first, second];
}
const pair = createPair(42, "debug"); // [number, "debug"],无需显式写 <number, string>
只有两种情况需要显式标注泛型参数:一是推断不出来(推断依赖的参数是可选的),二是需要比推断结果更宽的类型。能推断就别手写,显式泛型参数是"编译器搞不定"的信号,不是风格问题。
条件类型与 infer
条件类型是"类型层面的 if":
type IsString<T> = T extends string ? true : false;
type A = IsString<"x">; // true
type B = IsString<42>; // false
单独使用价值不大,真正的用法是配合 infer 在匹配中"捕获"一个位置的类型。标准库的 ReturnType 就是这么实现的:
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
function loadConfig() {
return { host: "localhost", port: 8080 };
}
type Config = MyReturnType<typeof loadConfig>;
// { host: string; port: number } —— 从函数实现处自动同步,改函数不用改类型
infer R 的含义是"假设这个位置的类型叫 R,匹配成功就把 R 交出来"。典型场景是从 Promise<T> 里取出 T:
type AwaitedValue<T> = T extends Promise<infer V> ? AwaitedValue<V> : T;
type D1 = AwaitedValue<Promise<Promise<number>>>; // number,递归解包
注意一个容易踩的坑:裸类型参数的条件类型是分发性的。T extends U ? X : Y 中的 T 如果是联合类型,会拆开逐个判断再合并:
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>; // string[] | number[],而不是 (string | number)[]
// 想禁止分发,用数组包一层:[T] extends [any] ? T[] : never
模板字面量类型
模板字面量类型让"字符串字面量的组合规律"变成可校验的类型。最典型的场景是事件系统:
interface Events {
click: { x: number; y: number };
focus: undefined;
submit: { value: string };
}
type EventName = keyof Events; // "click" | "focus" | "submit"
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onSubmit" —— 手写这三种之一必然一致
它和映射类型组合时威力最大:对象上有 onClick 这类回调,但没有 click 时,可以用键重映射反向生成。四个内置的字符串操作类型如下:
| 内置类型 | 作用 | 示例结果 |
|---|---|---|
Uppercase<S> | 转大写 | "CLICK" |
Lowercase<S> | 转小写 | "click" |
Capitalize<S> | 首字母大写 | "Click" |
Uncapitalize<S> | 首字母小写 | "click" |
适用边界:事件名、路由参数键、data-* 属性这类有明确生成规律的字符串用模板字面量类型收益很高;如果字符串集合本身没有规律(比如枚举翻译表),直接写字面量联合更清楚。
映射类型与 as 修饰
映射类型基于旧类型派生新类型,等号左边声明"键的来源",右边声明"值的形态":
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface Point { x: number; y: number }
type MaybePoint = Nullable<Point>; // { x: number | null; y: number | null }
TS 4.1 之后支持用 as 重映射键,这在"改键名"场景里替代了过去手写映射的写法:
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
interface Person { name: string; age: number }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
as 右侧可以过滤键:如果重映射结果是 never,这个键直接从结果中删除:
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
type OnlyStrings = OmitByType<{ a: string; b: number; c: boolean }, boolean>;
// { a: string; b: number } —— c 被过滤
修饰符 readonly 和 ? 可以用 +/- 增删,Mutable<T>(标准库没有,社区常见)就是 -readonly 的产物。判断该不该用复杂映射类型的标准很简单:如果这个映射类型只在一个文件用一次,直接手写目标类型的成本更低。
类型收窄:从联合到具体
类型收窄是把宽类型在某个分支内变成窄类型的过程。前端最高频的场景是"接口返回的数据结构有多种可能"。
判别联合:给每个分支一个身份证
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(state: RequestState): string {
switch (state.status) {
case "success":
return state.data.join(","); // 这里知道 data 一定存在
case "error":
return state.message; // 这里知道 message 一定存在
default:
return "等待中";
}
}
判别联合的关键是每个分支共享一个字面量类型的公共字段。编译器看到 state.status === "success" 就能排除其他分支。没有判别字段的多态结构(比如 { data?: string[]; error?: string })要靠大量非空断言才能工作,是典型的反模式。
自定义类型守卫与断言函数
当收窄逻辑没法用一个比较表达式表达时,写 is 守卫函数:
interface Fish { swim(): void }
interface Bird { fly(): void }
function isFish(pet: Fish | Bird): pet is Fish {
return "swim" in pet;
}
守卫函数的运行时实现是你自己的责任——类型系统只是采纳你的断言。所以守卫函数必须有测试覆盖,写错了它会安静地把错误放进所有使用点。断言函数(asserts)是它的变体,适合"不满足就抛错"的前置检查:
function assertDefined<T>(value: T | undefined, label: string): asserts value is T {
if (value === undefined) {
throw new Error(`缺少必填字段: ${label}`);
}
}
各收窄工具的适用面:
| 工具 | 适用场景 | 成本 |
|---|---|---|
| 字面量比较 / switch | 判别联合 | 零成本,首选 |
typeof x === "string" | 原始类型联合 | 零成本 |
"prop" in x | 对象形状差异 | 零成本 |
instanceof | 类实例 | 注意跨 realm(iframe/多窗口)会失效 |
is 守卫函数 | 需要逻辑判断的收窄 | 需要测试覆盖实现 |
as 断言 | 类型确实知道而编译器不知道 | 豁免检查,出错不报 |
unknown 与 any 的边界
any 关闭检查,unknown 强制你先收窄。这是同一个问题的两种答案:
| 维度 | any | unknown |
|---|---|---|
| 赋值给它 | 任何类型都行 | 任何类型都行 |
| 从它取值 | 任何操作都放行 | 不收窄就不能用 |
| 传播性 | 会"感染"整个调用链 | 强迫每个使用点单独收窄 |
| 适用场景 | 旧代码渐进迁移、与动态脚本桥接 | 外部输入:JSON.parse、catch 的 e、第三方回调参数 |
实际代码里最典型的分界线是"系统边界"。外部数据进门先转成 unknown,再用自己的校验收窄:
function parseEnv(raw: string): { port: number } {
const obj: unknown = JSON.parse(raw);
if (
typeof obj === "object" && obj !== null &&
"port" in obj && typeof (obj as { port: unknown }).port === "number"
) {
return { port: obj.port };
}
throw new Error("环境变量格式不合法");
}
catch (e) 里的 e 在默认配置下是 unknown(useUnknownInCatchVariables),直接 e.message 编译不过——这是刻意设计,因为抛出来的东西未必是 Error。处理方式是 e instanceof Error ? e.message : String(e),而不是把配置关掉。
声明文件与第三方类型扩展
三种场景需要写声明代码,做法各不相同:
| 场景 | 做法 | 风险点 |
|---|---|---|
| 包没有类型 | 写 declare module "pkg-name" 或本地 .d.ts | 声明写错了编译器不会发现,等于自己给自己盖章 |
| 想给包补充声明 | 模块扩展(module augmentation) | 与包升级后的官方类型冲突 |
| 全局工具类型 | global.d.ts 里 declare global | 污染全局命名空间,跨项目习惯冲突 |
模块扩展的写法:
// vue.d.ts —— 给组件实例补充一个自定义属性
import "vue";
declare module "vue" {
interface ComponentCustomProperties {
$analytics: { track(name: string): void };
}
}
原则:本地声明只在"类型缺口真实存在且影响开发"时才写,而且要先查 @types/ 仓库——绝大多数没有自带类型的包,社区类型包已经存在。自己写的声明没有编译校验,包一升级就可能静默失真,它是技术债不是资产。
工程边界:什么时候该停
类型体操的演示和工程是两回事。演示追求"用一个类型表达一个难点",工程追求"下一个改代码的人不比作者吃亏"。几个可操作的停机信号:
| 信号 | 说明 |
|---|---|
| 类型报错信息读不懂 | 使用者会直接 as any 绕过,复杂类型等于没写 |
| 需要写 10 行以上工具类型解决一个业务问题 | 运行时校验 3 行就能解决,而且更可靠 |
| 类型体操只服务于"聪明"而非具体错误 | 收益为零,纯维护成本 |
| 编译时间明显劣化 | 深层递归条件类型是常见元凶 |
| 新人接手两周还在问类型含义 | 类型系统本来是给人降低成本的 |
可执行的替代策略:
- 宽类型 + 运行时校验:外部输入用 unknown 进门、zod/valibot 这类 schema 校验收窄,schema 同时产出类型(
z.infer),比手写深度类型可靠。 - 复杂逻辑显式化:一个 30 行的条件类型拆成三个命名清晰的小类型,中间步骤各自可读。
- 删掉没有使用者的工具类型:没有调用方的类型体操是死代码,和普通死代码同罪。
- 禁止
as any与@ts-ignore无注释:团队规则里要求每个豁免写明原因和失效条件,豁免点集中在一个文件里可审计。
注意事项与边界
- 类型擦除意味着任何"用类型做运行时行为"的想法都不成立,需要运行时存在的结构要靠 JS 实现。
strict全开(尤其strictNullChecks)是所有进阶技巧的前提,没开 strict 的项目里联合类型收窄的讨论都失去意义。- 递归类型有深度上限,错误通常表现为"Type instantiation is excessively deep",这是编译器保护,不要硬绕。
- 结构化类型意味着两个形状相同的类型可以互换——想表达"必须是我声明的这个"要用品牌类型(branded type)。
- 库作者和业务开发者应该用不同的类型复杂度预算:库的类型签名会被成千上万人使用,多花成本的回报率高;业务代码大多数类型活不过一次重构。
- 类型断言
as不做转换,只做"信任我"声明,和unknown组合时尤其要确认自己验证过数据。
总结
进阶类型的正确用法有一条主线:约束保证函数体合法,推断减少调用方成本,收窄让联合类型的每个分支都可安全访问。条件类型、模板字面量、映射类型是这三件事的组合放大器;unknown 是系统边界上的正确默认。而复杂度预算是最后一个决定:报错信息读不懂、只在演示里工作、需要运行时配合的类型,应该被替换成更简单的宽类型加运行时校验。类型系统是降低总成本的工具,当它自己成为成本时,就是停下来的时机。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。