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");
|
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;
|
一个常见场景是封装安全的取值函数:
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");
const age = getValue(user, "age");
|
这里 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;
type RoutePath = (typeof routes)[RouteName];
|
如果希望 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];
|
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;
|
如果写错字段,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>;
type B = IsString<number>;
|
更实用的例子是根据 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>;
|
这里用到了 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>>;
type B = UnwrapPromise<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>;
|
提取函数返回值:
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>;
|
TypeScript 内置的 ReturnType、Parameters、Awaited 等工具类型背后也大量使用了类似思路。
模板字面量类型:拼接字符串类型
模板字面量类型可以基于字符串字面量生成新的字符串类型。
1 2 3 4 5
| type Method = "GET" | "POST" | "PUT" | "DELETE"; type Path = "/users" | "/posts";
type ApiKey = `${Method} ${Path}`;
|
可以用它约束事件名:
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");
|
在实际项目里,它适合做:
- 事件名约束
- 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>;
|
提取异步函数返回值:
1 2 3 4 5 6
| async function fetchUser() { return { id: "1", name: "Ada" }; }
type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
|
类型守卫:把运行时判断变成类型信息
有时候数据来自接口,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];
|
这在封装配置函数时很方便,比如:
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", { 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");
|
这样可以把一部分状态机规则提前放到类型系统里。当然,运行时仍然要做校验,因为用户输入和服务端数据不一定可信。
什么时候不该写复杂类型
TypeScript 高级类型不是越复杂越好。类型系统的目标是帮团队减少错误,而不是制造阅读成本。
不建议过度使用复杂类型的场景:
- 业务逻辑本身很简单。
- 类型写出来比实现还难懂。
- 错误提示已经很难读。
- 团队成员很难维护。
- 运行时校验才是真正关键点。
一个比较实用的判断标准是:如果这个类型能明显减少重复、约束关键业务规则、提升调用体验,就值得写;如果只是为了少写几行普通类型,可能没必要。
总结
TypeScript 的高级能力可以分成几类:
keyof、typeof、as const:从值和对象中提取类型。
- 泛型约束:表达输入和输出之间的关系。
- 映射类型:批量改造对象字段。
- 条件类型和
infer:根据输入类型计算输出类型。
- 模板字面量类型:约束有规律的字符串。
satisfies:校验配置结构,同时保留精确字面量类型。
- 类型守卫:把运行时判断转成类型收窄。
- 工具类型:复用 TypeScript 内置能力。
真正写项目时,不需要为了“高级”而高级。最好的类型设计,是让业务代码更少出错、更容易补全、更容易重构,并且让下一位维护者能看懂。