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

标准库里的 ExtractExclude 就是条件类型写的。试着自己实现一遍,比看十遍文档都管用:

// 从 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、配置校验时特别香——把"拼写错误"挡在编译期。


五、递归类型

类型可以是递归的。配合 readonlyobject 判断,能表达深层不可变结构:

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)

相比 assatisfies 是"校验但不改写",安全得多。

与 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 一次性打开 noImplicitAnystrictNullChecks 等一揽子检查。短期会多出不少报错要修,长期能消灭一整类 undefined is not a function

Q4:泛型约束 extends 和继承是一回事吗?

不是。T extends Foo 在这里表示"T 必须能赋值给 Foo",和面向对象的 extends 是巧合同名。把它读成"受限于"更不容易迷糊。


工具推荐

写类型、和 JSON 打交道时,下面几个 ToolsKu 工具能省不少事:


类型系统的尽头不是"让编译器闭嘴",而是"让它在你犯错之前开口"。把类型当同事,而不是当监工。

本站提供浏览器本地工具,免注册即可试用 →

#TypeScript#类型系统#类型体操#泛型#前端工程