TypeScript 的价值不只是“给变量加类型”,而是把很多运行时约定提前变成编译期约束。写业务代码时,基础类型、接口、泛型已经能解决大部分问题;但当项目变复杂,比如封装组件库、请求 SDK、表单模型、权限配置、事件系统时,就会经常用到一些更高级的类型能力。

这篇记录一些 TypeScript 里比较实用的高级用法。

类型收窄:让联合类型变得可控

联合类型很常见,比如接口返回状态、组件 props、任务状态等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Task =
| { status: "pending"; id: string }
| { status: "running"; id: string; startedAt: string }
| { status: "failed"; id: string; errorMessage: string }
| { status: "succeeded"; id: string; resultUrl: string };

function renderTask(task: Task) {
if (task.status === "failed") {
return task.errorMessage;
}

if (task.status === "succeeded") {
return task.resultUrl;
}

return task.id;
}

这里的 status 是判别字段,TypeScript 会根据 if 条件自动收窄类型。这样比写一堆可选字段更安全。

不推荐这样写:

1
2
3
4
5
6
type BadTask = {
status: "pending" | "running" | "failed" | "succeeded";
id: string;
errorMessage?: string;
resultUrl?: string;
};

因为 status === "failed" 时,errorMessage 在类型上仍然可能是 undefined。判别联合类型能把“某个状态下一定有哪些字段”表达得更准确。

never:保证分支穷尽

当联合类型变多时,很容易漏处理某个状态。可以用 never 做穷尽检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}

function getTaskText(task: Task) {
switch (task.status) {
case "pending":
return "等待中";
case "running":
return "运行中";
case "failed":
return task.errorMessage;
case "succeeded":
return task.resultUrl;
default:
return assertNever(task);
}
}

如果以后给 Task 新增一个 cancelled 状态,但忘记在 switch 里处理,assertNever(task) 会直接报类型错误。

这在状态机、订单状态、权限状态、Agent 步骤状态里都很有用。

泛型约束:既灵活又保留字段限制

泛型不是为了炫技,而是为了让函数在复用时保留输入和输出之间的关系。

比如写一个根据 id 找元素的函数:

1
2
3
4
5
6
7
8
9
10
11
function findById<T extends { id: string }>(list: T[], id: string): T | undefined {
return list.find((item) => item.id === id);
}

const users = [
{ id: "u1", name: "Ada" },
{ id: "u2", name: "Grace" },
];

const user = findById(users, "u1");
// user 的类型是 { id: string; name: string } | undefined

T extends { id: string } 表示:调用者传什么对象都可以,但必须有 id 字段。返回值仍然保留原来的完整类型。

keyof:把对象 key 变成类型

keyof 可以拿到对象类型的所有 key。

1
2
3
4
5
6
7
8
type User = {
id: string;
name: string;
email: string;
};

type UserKey = keyof User;
// "id" | "name" | "email"

一个常见场景是封装安全的取值函数:

1
2
3
4
5
6
7
8
9
10
11
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { id: "1", name: "Ada", age: 28 };

const name = getValue(user, "name");
// name: string

const age = getValue(user, "age");
// age: number

这里 K extends keyof T 限制 key 必须来自对象本身,T[K] 则能推导出对应字段的值类型。

typeof:从值反推类型

很多时候配置是先写出来的,类型希望从配置自动推导,而不是手写一遍。

1
2
3
4
5
6
7
8
9
10
11
const routes = {
home: "/",
about: "/about",
dashboard: "/dashboard",
};

type RouteName = keyof typeof routes;
// "home" | "about" | "dashboard"

type RoutePath = (typeof routes)[RouteName];
// string

如果希望 value 保持字面量类型,可以加 as const

1
2
3
4
5
6
7
8
const routes = {
home: "/",
about: "/about",
dashboard: "/dashboard",
} as const;

type RoutePath = (typeof routes)[keyof typeof routes];
// "/" | "/about" | "/dashboard"

as const 会把对象变成只读,并把字符串值收窄成字面量类型。

satisfies:校验结构但保留字面量类型

satisfies 是一个非常适合写配置的语法。它既能检查对象是否符合某个类型,又不会像类型注解那样把字面量信息抹掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type MenuItem = {
label: string;
path: string;
external?: boolean;
};

const menus = {
ai: {
label: "AI",
path: "https://devflow.aiyly.com/",
external: true,
},
blog: {
label: "Blog",
path: "/",
},
} satisfies Record<string, MenuItem>;

type MenuKey = keyof typeof menus;
// "ai" | "blog"

如果写错字段,TypeScript 会报错;同时 menus 自己仍然保留精确的 key。

适合使用 satisfies 的场景:

  • 菜单配置
  • 路由配置
  • 权限配置
  • 表单 schema
  • 状态机配置
  • 多语言文案配置

映射类型:批量改造对象结构

映射类型可以遍历一个对象类型的所有 key。

1
2
3
4
5
6
7
8
9
10
11
type User = {
id: string;
name: string;
email: string;
};

type Nullable<T> = {
[K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;

NullableUser 等价于:

1
2
3
4
5
type NullableUser = {
id: string | null;
name: string | null;
email: string | null;
};

也可以让所有字段变成可选:

1
2
3
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type UserDraft = PartialBy<User, "email">;

这个类型表示:除了 email 可选,其他字段保持原样。

条件类型:根据输入类型计算输出类型

条件类型长得像三元表达式:

1
2
3
4
5
6
7
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;
// true

type B = IsString<number>;
// false

更实用的例子是根据 API 响应类型提取数据:

1
2
3
4
5
6
7
8
9
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; message: string };

type ExtractData<T> = T extends { success: true; data: infer D } ? D : never;

type UserResponse = ApiResponse<{ id: string; name: string }>;
type UserData = ExtractData<UserResponse>;
// { id: string; name: string }

这里用到了 infer

infer:在条件类型里提取局部类型

infer 可以理解成“在类型匹配时临时声明一个变量”。

提取 Promise 里的值:

1
2
3
4
5
6
7
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;

type A = UnwrapPromise<Promise<string>>;
// string

type B = UnwrapPromise<number>;
// number

提取数组元素:

1
2
3
4
5
type ArrayItem<T> = T extends Array<infer Item> ? Item : never;

type UserList = Array<{ id: string; name: string }>;
type UserItem = ArrayItem<UserList>;
// { id: string; name: string }

提取函数返回值:

1
2
3
4
5
6
7
8
type FnReturn<T> = T extends (...args: any[]) => infer R ? R : never;

function createUser() {
return { id: "1", name: "Ada" };
}

type CreatedUser = FnReturn<typeof createUser>;
// { id: string; name: string }

TypeScript 内置的 ReturnTypeParametersAwaited 等工具类型背后也大量使用了类似思路。

模板字面量类型:拼接字符串类型

模板字面量类型可以基于字符串字面量生成新的字符串类型。

1
2
3
4
5
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Path = "/users" | "/posts";

type ApiKey = `${Method} ${Path}`;
// "GET /users" | "GET /posts" | "POST /users" | ...

可以用它约束事件名:

1
2
3
4
5
6
7
8
9
10
type Model = "user" | "post" | "comment";
type EventName = `${Model}:created` | `${Model}:updated` | `${Model}:deleted`;

function emit(event: EventName) {
console.log(event);
}

emit("user:created");
emit("post:updated");
// emit("user:create"); // 类型错误

在实际项目里,它适合做:

  • 事件名约束
  • API key 约束
  • CSS token 名称
  • 权限标识
  • 表单字段路径

工具类型:不要重复造轮子

TypeScript 内置了很多常用工具类型。

1
2
3
4
5
6
type User = {
id: string;
name: string;
email: string;
password: string;
};

选出一部分字段:

1
type PublicUser = Pick<User, "id" | "name">;

排除一部分字段:

1
type SafeUser = Omit<User, "password">;

全部可选:

1
type UserPatch = Partial<User>;

全部必填:

1
type RequiredUser = Required<User>;

只读:

1
type ReadonlyUser = Readonly<User>;

构造对象字典:

1
type UserMap = Record<string, User>;

提取函数参数:

1
2
3
4
function updateUser(id: string, data: Partial<User>) {}

type UpdateUserParams = Parameters<typeof updateUser>;
// [id: string, data: Partial<User>]

提取异步函数返回值:

1
2
3
4
5
6
async function fetchUser() {
return { id: "1", name: "Ada" };
}

type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; name: string }

类型守卫:把运行时判断变成类型信息

有时候数据来自接口,TypeScript 并不知道它到底是什么结构。这时可以写类型守卫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type User = {
id: string;
name: string;
};

function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}

function handleValue(value: unknown) {
if (isUser(value)) {
return value.name;
}

return "unknown";
}

value is User 告诉 TypeScript:如果函数返回 true,那么这个值就可以当作 User 使用。

类型守卫适合处理:

  • 接口返回的 unknown 数据
  • localStorage 读取的数据
  • postMessage 数据
  • 第三方 SDK 回调
  • JSON.parse 的结果

const 泛型:保留更精确的字面量类型

TypeScript 5.0 引入了 const type parameters,可以让泛型函数保留更精确的字面量类型。

普通泛型:

1
2
3
4
5
6
7
8
function defineRoutes<T extends Record<string, string>>(routes: T) {
return routes;
}

const routes = defineRoutes({
home: "/",
about: "/about",
});

很多时候这样已经够用。如果希望参数推导更接近 as const,可以使用 const 泛型:

1
2
3
4
5
6
7
8
9
10
11
function defineRoutes<const T extends Record<string, string>>(routes: T) {
return routes;
}

const routes = defineRoutes({
home: "/",
about: "/about",
});

type RoutePath = (typeof routes)[keyof typeof routes];
// "/" | "/about"

这在封装配置函数时很方便,比如:

  • defineRoutes
  • defineMenu
  • definePermissions
  • defineStore
  • defineEvents

实战:封装一个类型安全的请求函数

假设后端接口都有统一返回格式:

1
2
3
type ApiResult<T> =
| { success: true; data: T }
| { success: false; message: string };

定义接口映射:

1
2
3
4
5
6
7
8
9
10
type ApiSchema = {
"GET /api/users": {
query: { page: number };
response: { id: string; name: string }[];
};
"POST /api/users": {
body: { name: string; email: string };
response: { id: string };
};
};

提取请求参数:

1
2
3
4
5
6
7
8
type RequestConfig<K extends keyof ApiSchema> =
ApiSchema[K] extends { query: infer Q }
? { query: Q }
: ApiSchema[K] extends { body: infer B }
? { body: B }
: never;

type ResponseData<K extends keyof ApiSchema> = ApiSchema[K]["response"];

封装请求函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function request<K extends keyof ApiSchema>(
key: K,
config: RequestConfig<K>,
): Promise<ApiResult<ResponseData<K>>> {
const [method, url] = key.split(" ");

const response = await fetch(url, {
method,
body: "body" in config ? JSON.stringify(config.body) : undefined,
});

return response.json();
}

调用时就能自动推导参数和返回值:

1
2
3
4
5
6
7
8
9
10
const result = await request("POST /api/users", {
body: {
name: "Ada",
email: "ada@example.com",
},
});

if (result.success) {
result.data.id;
}

如果传错参数,编译期就会报错:

1
2
3
4
5
6
7
request("POST /api/users", {
// query: { page: 1 }, // 类型错误,POST /api/users 需要 body
body: {
name: "Ada",
email: "ada@example.com",
},
});

这类模式很适合小中型项目。如果项目更大,可以继续演进成基于 OpenAPI、tRPC 或 RPC schema 的方案。

实战:用类型约束状态机

前端经常会遇到状态流转,比如任务状态、订单状态、工作流步骤。

先定义状态和事件:

1
2
3
4
5
6
7
8
type Status = "pending" | "running" | "waiting_user_confirm" | "succeeded" | "failed";

type Event =
| { type: "start" }
| { type: "confirm" }
| { type: "success" }
| { type: "fail"; message: string }
| { type: "retry" };

定义允许的流转:

1
2
3
4
5
6
7
const transitions = {
pending: ["running"],
running: ["waiting_user_confirm", "succeeded", "failed"],
waiting_user_confirm: ["running"],
succeeded: [],
failed: ["pending"],
} as const satisfies Record<Status, readonly Status[]>;

提取某个状态允许流转到的状态:

1
2
type Transitions = typeof transitions;
type NextStatus<S extends Status> = Transitions[S][number];

封装流转函数:

1
2
3
4
5
6
7
function transition<S extends Status>(from: S, to: NextStatus<S>) {
return to;
}

transition("pending", "running");
transition("failed", "pending");
// transition("succeeded", "running"); // 类型错误

这样可以把一部分状态机规则提前放到类型系统里。当然,运行时仍然要做校验,因为用户输入和服务端数据不一定可信。

什么时候不该写复杂类型

TypeScript 高级类型不是越复杂越好。类型系统的目标是帮团队减少错误,而不是制造阅读成本。

不建议过度使用复杂类型的场景:

  • 业务逻辑本身很简单。
  • 类型写出来比实现还难懂。
  • 错误提示已经很难读。
  • 团队成员很难维护。
  • 运行时校验才是真正关键点。

一个比较实用的判断标准是:如果这个类型能明显减少重复、约束关键业务规则、提升调用体验,就值得写;如果只是为了少写几行普通类型,可能没必要。

总结

TypeScript 的高级能力可以分成几类:

  • keyoftypeofas const:从值和对象中提取类型。
  • 泛型约束:表达输入和输出之间的关系。
  • 映射类型:批量改造对象字段。
  • 条件类型和 infer:根据输入类型计算输出类型。
  • 模板字面量类型:约束有规律的字符串。
  • satisfies:校验配置结构,同时保留精确字面量类型。
  • 类型守卫:把运行时判断转成类型收窄。
  • 工具类型:复用 TypeScript 内置能力。

真正写项目时,不需要为了“高级”而高级。最好的类型设计,是让业务代码更少出错、更容易补全、更容易重构,并且让下一位维护者能看懂。