TypeScript 高级类型实战:从条件类型到类型体操
一次让我重新认识类型的重构
去年我们对一个中后台项目做字段改名:把 userProfile 上的 avatarUrl 改名为 avatar。这是个看似安全的改动——直到 TypeScript 在 31 个文件里同时报红。我们本以为只是改个名字,结果编译器把每一处拿 avatarUrl 拼接 CDN 地址、做空值兜底、传给 <img src> 的地方都标了出来。
如果当时用的是 JavaScript,这些报错会延迟到运行时、甚至到用户浏览器里才暴露。那一晚让我意识到:类型不是负担,是编译器在替你做免费的代码评审。
这篇文章不讲基础语法,假设你已经会用 interface、泛型。我们直接钻进那些"看起来很吓人、用熟了舍不得丢"的高级类型。
一、先把地基打牢:keyof 与索引访问
很多高级写法都建立在两个原语上。
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
type IdType = User["id"]; // number
type NameOrEmail = User["name" | "email"]; // string
keyof T 拿到所有键的联合,T[K] 做索引访问。把它们组合,就能写出"根据键取对应值类型"的工具:
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: "Ada", email: "a@x.com" };
const name = pick(user, "name"); // 类型推断为 string
注意第二个泛型参数 K extends keyof T——这是约束,保证调用方传的 key 一定存在于对象上,传错直接编译报错。
typeof 在"值"上的妙用
typeof 在类型位置使用时,是把一个值反向推导成类型,常用于从运行时代码里提取类型,避免重复声明:
const config = {
apiBase: "https://api.example.com",
timeout: 8000,
retries: 3,
};
type Config = typeof config;
// { apiBase: string; timeout: number; retries: number }
二、条件类型:类型的三元表达式
条件类型的形态是 T extends U ? X : Y,读起来就是"如果 T 能赋值给 U,用 X,否则用 Y"。
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
标准库里的 Extract 和 Exclude 就是条件类型写的。试着自己实现一遍,比看十遍文档都管用:
// 从 T 中"提取"能赋值给 U 的成员
type MyExtract<T, U> = T extends U ? T : never;
// 从 T 中"排除"能赋值给 U 的成员
type MyExclude<T, U> = T extends U ? never : T;
type T0 = MyExtract<"a" | "b" | "c", "a" | "c">; // "a" | "c"
type T1 = MyExclude<"a" | "b" | "c", "a" | "c">; // "b"
理解关键点:当 T 是联合类型时,条件类型会对每个成员分别求值,再把结果联合起来。这正是 MyExclude 能逐个剔除成员的原因。
infer:从类型里"掏"出一部分
infer 只能在 extends 条件分支里用,意思是"这里先别写死,让编译器帮我推断"。它让类型具备了"解构"能力。
// 取数组第一个元素类型
type First<T extends unknown[]> = T extends [infer Head, ...unknown[]]
? Head
: never;
type F = First<[string, number, boolean]>; // string
// 手写一个 ReturnType
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function loadUser() {
return { id: 1, name: "Ada" };
}
type Loaded = MyReturnType<typeof loadUser>; // { id: number; name: string }
infer 可以出现在返回值、参数、Promise 内部、数组元素等任意位置。Awaited<T> 就是靠它逐层剥开 Promise 的:
type MyAwaited<T> = T extends Promise<infer U>
? U extends Promise<unknown>
? MyAwaited<U>
: U
: T;
type R = MyAwaited<Promise<Promise<number>>>; // number
三、映射类型与 key 重映射
映射类型让你"遍历一个类型的所有键",像对对象做 map。
type PartialUser = {
[K in keyof User]?: User[K];
};
// { id?: number; name?: string; email?: string }
+/- 修饰符能增删 readonly 和 ?:
type Writable<T> = {
-readonly [K in keyof T]: T[K];
};
key 重映射(TypeScript 4.1+)
as 子句可以在映射时重写键名,这是实现各种 getter/事件映射的利器:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// }
结合 Exclude 还能"过滤"掉某些键:
type MethodsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
当 as 的结果是 never,该键就被直接剔除。这个技巧在裁剪类型时极好用。
四、模板字面量类型
模板字面量类型可以拼接字符串字面量,常用于约束事件名、路由、CSS 属性等:
type Lang = "zh" | "en" | "ja";
type Path = `/i18n/${Lang}`; // "/i18n/zh" | "/i18n/en" | "/i18n/ja"
type EventName = `on${Capitalize<"click" | "change" | "submit">}`;
// "onClick" | "onChange" | "onSubmit"
实战中用它给一个 CSS 属性生成类型安全的 setter:
type Spacing = "sm" | "md" | "lg";
type MarginClass = `m-${Spacing}` | `mt-${Spacing}` | `mb-${Spacing}`;
// "m-sm" | "m-md" | "m-lg" | "mt-sm" | ...
function cx(...classes: MarginClass[]) {
return classes.join(" ");
}
cx("mt-md", "mb-lg"); // OK
// cx("padding-sm"); // 报错:不在允许集合内
这类约束在写组件库、DSL、配置校验时特别香——把"拼写错误"挡在编译期。
五、递归类型
类型可以是递归的。配合 readonly 和 object 判断,能表达深层不可变结构:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const cfg: DeepReadonly<User> = { id: 1, name: "Ada", email: "a@x.com" };
// cfg.name = "Bob"; // 报错:无法分配到只读属性
再写一个 DeepPartial,常用于"表单草稿"——用户只填了一部分字段,其余可空:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
JSON 序列化类型也是递归题的经典:
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
六、实战:解析查询字符串类型
类型体操圈有一道名题:把 "a=1&b=2&c=3" 这样的字符串,解析成 { a: string; b: string; c: string } 类型。完整实现挺长,这里给一个能跑、易读的版本,思路是"按 & 拆、按 = 拆、逐个累加":
type Split<S extends string, D extends string> =
string extends S ? string[]
: S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type ParsePair<P extends string> =
P extends `${infer K}=${infer V}` ? { [k in K]: V } : {};
type Merge<T> =
(T extends any ? (k: T) => void : never) extends (k: infer U) => void
? U
: never;
type ParseQueryString<S extends string> =
Merge<ParsePair<Split<S, "&">[number]>>;
type Q = ParseQueryString<"a=1&b=2&c=3">;
// { a: "1" } & { b: "2" } & { c: "3" } 的交叉,即三者合并
这道题的价值不在"记住答案",而在于它逼你把 infer、递归、as 重映射、[number] 取联合成员全部串起来。建议自己写一遍再对照。
七、类型与运行时的边界
类型在编译后会被抹除,所以"类型里做的判断"运行时不存在。需要运行时校验时,有两个常见伙伴:
类型守卫
function isUser(v: unknown): v is User {
return (
typeof v === "object" &&
v !== null &&
"id" in v &&
"name" in v
);
}
satisfies:既要值正确,又要保留字面量类型
satisfies 是 4.9 引入的宝藏。它不拓宽类型,却能检查值是否符合某类型:
const routes = {
home: "/",
user: "/users/:id",
} satisfies Record<string, string>;
type RouteName = keyof typeof routes; // "home" | "user"(而非 string)
相比 as,satisfies 是"校验但不改写",安全得多。
与 zod 配合
当你需要"运行时也验证",可以用 zod 这类库,再用 z.infer 把 schema 推导回类型,做到一份定义两端共用:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>; // 与 schema 永远同步
八、性能坑:实例化深度超限
类型越复杂,编译器要做的工作越多。条件类型里写 T extends ... ? ... : ... 时,如果递归层级太深,会撞上 error TS2589: Type instantiation is excessively deep and possibly infinite。
几个缓解办法:
- 用数组索引代替超长元组链:
T extends [infer H, ...infer R]通常比层层嵌套的三元更稳。 - 给递归加终止条件,并确保每次递归都在"缩小"类型。
- 避免无意义的深层映射,必要时拆成多个中间类型,让编译器分步求解。
- 真遇到 TS2589,先想"我是不是在类型里重写了一个本该运行时的逻辑"。类型系统不是图灵机,强行模拟循环往往得不偿失。
九、为 API 响应建模
把上面这些用到真实场景:给 fetch 包一层类型安全的客户端,让"成功/失败"在类型上可区分。
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function getJson<T>(url: string): Promise<ApiResult<T>> {
try {
const res = await fetch(url);
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
const data = (await res.json()) as T;
return { ok: true, data };
} catch (e) {
return { ok: false, error: String(e) };
}
}
interface Order { id: number; total: number }
const r = await getJson<Order>("/api/orders/1");
if (r.ok) {
console.log(r.data.total); // 类型收窄后安全访问
} else {
console.error(r.error);
}
把"失败"做成显式分支而不是抛异常,调用方不得不处理错误,比 any 裸奔稳得多。
常见问题
Q1:类型体操值得花时间学吗?
如果你写业务 CRUD,掌握条件类型、infer、映射类型、模板字面量这四类,足以应付 90% 的场景。真正的"体操题"更多是训练思维。别为了炫技把团队代码写成天书——可读性也是类型的一部分。
Q2:interface 和 type 现在还有区别吗?
日常用几乎可互换。区别主要在:接口可声明合并(同名下合并),类型别名能用联合、条件、映射。个人习惯:描述"对象形状"用 interface,做"类型变换"用 type。
Q3:要不要全员开启 strict 模式?
强烈建议。strict: true 一次性打开 noImplicitAny、strictNullChecks 等一揽子检查。短期会多出不少报错要修,长期能消灭一整类 undefined is not a function。
Q4:泛型约束 extends 和继承是一回事吗?
不是。T extends Foo 在这里表示"T 必须能赋值给 Foo",和面向对象的 extends 是巧合同名。把它读成"受限于"更不容易迷糊。
工具推荐
写类型、和 JSON 打交道时,下面几个 ToolsKu 工具能省不少事:
- JSON → TypeScript — 粘一段样本 JSON,自动推断 interface
- 通用代码格式化器 — 顺手把类型定义排整齐
- 正则大全 — 模板字面量类型里写正则前先在这里验证
类型系统的尽头不是"让编译器闭嘴",而是"让它在你犯错之前开口"。把类型当同事,而不是当监工。
本站提供浏览器本地工具,免注册即可试用 →