02 - TS 进阶类型
前置:TS 基础与类型系统。本章是日常 TS 开发的主力武器库:联合类型建模状态、类型收窄、泛型与工具类型。掌握后可以覆盖 90% 的业务类型标注需求。
1. 联合类型与字面量类型
1.1 字面量类型:把值变成类型
字面量本身也可以是类型——变量只能取这几个特定值:
type Direction = "up" | "down" | "left" | "right";
type Dice = 1 | 2 | 3 | 4 | 5 | 6;
let dir: Direction = "up"; // 只能是这四个字符串之一
// dir = "north"; // 编译错误,拼错的方向直接被拦截单看这个例子价值有限,但联合上对象就威力巨大。
1.2 联合类型建模状态:本节最重要模式
对照 JS 的常见写法:用数字魔法值 status = 0/1/2 或随意字符串,合法值全靠口口相传。字面量联合把”所有合法状态”显式声明为类型,IDE 补全会列出全部选项,拼错即报错——这是 TS 对业务代码最大的日常增益:
type Status = "idle" | "loading" | "success" | "error";
interface State {
status: Status;
data: Todo[];
error?: string;
}
const state: State = { status: "idle", data: [] };
// 状态迁移被约束得明明白白
state.status = "loading";
// state.status = "loaded"; // 编译错误!不存在的状态进不来Java 类比:等价于枚举 enum Status { IDLE, LOADING, ... },但零运行时成本且可以直接组合(见下文判别联合)。
1.3 交叉类型
交叉 & 要求同时满足多个形状:
interface Loggable { timestamp: Date }
interface Identifiable { id: number }
type AuditRecord = Identifiable & Loggable;
// 必须同时有 id 和 timestamp
const record: AuditRecord = {
id: 1,
timestamp: new Date(),
};联合是”或”,交叉是”且”。注意交叉冲突时的行为:两个同名字段类型不同会得到 never 类型(永远无法满足),编译器会用它暴露设计矛盾。---
2. 类型收窄 Narrowing
联合类型的变量在使用前必须确定具体分支——收窄(narrowing)就是 TS 根据运行时检查自动缩小类型范围的过程。
function format(value: string | number) {
// 这里 value 是 string | number,不能直接调用任何一方的方法
if (typeof value === "string") {
return value.trim(); // 收窄为 string
}
return value.toFixed(2); // 剩余分支收窄为 number
}四种收窄手段逐一过:
// 手段一:typeof —— 收窄原始类型(排除法同样有效)
if (typeof input === "boolean") {
input.valueOf(); // boolean 分支
} else if (typeof input === "string") {
input.toUpperCase();
} else {
input * 2; // 排除前两种,剩下必是 number
}
// 手段二:in —— 检查属性是否存在,收窄对象联合
interface Bird { fly(): void }
interface Fish { swim(): void }
function move(animal: Bird | Fish) {
if ("swim" in animal) animal.swim(); // Fish
else animal.fly(); // Bird
}
// 手段三:严格判等 —— 与字面量比较后分支内即为该字面量类型
if (theme === "auto") { /* theme: "auto" */ }
// 手段四:instanceof —— 收窄类实例
if (x instanceof Date) x.getTime();
else x.message;2.1 可辨识联合与穷尽性检查
最强大的收窄场景:多个接口共享同一个字面量标签字段(discriminant),switch 该字段时每个 case 内自动获得精确类型:
interface LoadingState { tag: "loading" }
interface SuccessState { tag: "success"; data: string[] }
interface ErrorState { tag: "error"; message: string }
type FetchState = LoadingState | SuccessState | ErrorState;
function render(state: FetchState): string {
switch (state.tag) {
case "loading":
return "加载中..."; // state 是 LoadingState
case "success":
return state.data.join(","); // 只有 SuccessState 有 data
case "error":
return `出错了: ${state.message}`;
}
}每个分支里访问的属性都保证存在——不可能在 loading 态误读 data。这正是 Redux 等状态管理库处理 action 的标准范式。
穷尽性检查(exhaustive check):利用 never 确保”新增联合成员时所有 switch 都被迫更新”:
function renderSafe(state: FetchState): string {
switch (state.tag) {
case "loading": return "...";
case "success": return state.data.join(",");
case "error": return state.message;
default: {
const _exhaustive: never = state; // 若漏了某个 case,这里编译报错
throw new Error(`未处理的状态: ${_exhaustive}`);
}
}
}将来给联合加一个 "cancelled" 成员而忘记更新 render 时,never 断言处立刻红——编译器逼你补齐所有分支。这是 TS 版的”编译期防御性编程”。
3. 泛型
3.1 函数泛型
泛型的意义与 Java 一致:类型的参数化,一份逻辑适配多种类型且全程保持精确类型:
// 不用泛型的两种坏方案:
// function first(arr: any[]): any —— 丢失类型
// function first(arr: number[]|string[]): number|string —— 组合爆炸
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined,自动推断 T=number
const s = first(["a", "b"]); // s: string | undefined对照 Java:<T> T first(List<T> arr),语法几乎逐字符对应。区别是 TS 泛型默认擦除方式更简单(本来运行时就没有类型),且支持结构化约束。
3.2 泛型约束 extends
限制 T 的形状——只有具备某结构的类型才能传入:
// 要求参数至少有 length 属性
function logLength<T extends { length: number }>(item: T): T {
console.log(item.length);
return item;
}
logLength("hello"); // 合法,string 有 length
logLength([1, 2]); // 合法,数组有 length
// logLength(123); // 编译错误,number 没有 length
// 多个泛型参数互相约束:K 必须是 obj 的键
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "张三", age: 20 };
getProp(user, "name"); // 类型精确为 string
getProp(user, "age"); // number
// getProp(user, "email"); // 编译错误:"email" 不在 user 的键中keyof T 本身也值得单独记:它取出对象类型的所有键组成字面量联合。keyof typeof user 即 "name" | "age"——typeof 在类型上下文中取值的类型,两者组合是从”值世界”通往”类型世界”的桥梁。
3.3 泛型接口与泛型默认值
// 泛型容器接口:类似 Java 的 List<T> / Map<K,V>
interface Result<T> {
code: number;
message: string;
data: T;
}
function ok<T>(data: T): Result<T> {
return { code: 0, message: "ok", data };
}
// 泛型默认值:未指定时使用兜底类型
interface Paginated<T = unknown> {
list: T[];
page: number;
pageSize: number;
}
type AnyPage = Paginated; // list: unknown[]
type UserPage = Paginated<{ id: number }>; // 按需指定4. 常用工具类型八个
工具类型是 TS 内置的”类型函数”,基于映射类型实现。以下八个覆盖绝大多数日常场景,逐一示例。
准备公共类型:
interface Todo {
id: number;
title: string;
done: boolean;
dueAt?: Date;
tags: string[];
}4.1 Partial<T>:全变可选
更新操作的标配——只传想改的字段:
function updateTodo(id: number, patch: Partial<Todo>) { /* ... */ }
updateTodo(1, { done: true }); // 只改完成态
updateTodo(1, { title: "新标题", done }); // 改多项,均合法4.2 Required<T>:全变必选
Partial 的逆运算,常用于校验”数据已完整”的场景:
type CompleteTodo = Required<Todo>;
// dueAt 从可选变为必填4.3 Readonly<T>:全变只读
const frozen: Readonly<Todo> = {
id: 1, title: "学习TS", done: false, tags: ["study"],
};
// frozen.done = true; // 编译错误:只读对象不可修改
frozen.tags.push("x"); // 注意:浅只读,嵌套数组仍可变!4.4 Pick<T, K>:挑取子集
type TodoSummary = Pick<Todo, "id" | "title">;
// 等价于 { id: number; title: string }
function renderRow(todo: TodoSummary) {
return `${todo.id}: ${todo.title}`;
}从大类型中抽取视图真正需要的字段,避免重复定义导致的两处维护。
4.5 Omit<T, K>:剔除子集
Pick 的补集视角:
type NewTodo = Omit<Todo, "id" | "done">;
// 创建时的输入形状:没有 id(后端生成)和 done(初始 false)
function createTodo(input: NewTodo) { /* ... */ }
createTodo({ title: "买牛奶", tags: ["生活"] });4.6 Record<K, V>:构造键值字典
const config: Record<string, number> = { timeout: 3000, retry: 3 };
// 键受限的字典:配合字面量联合,强制每个键都有值
type Status = "idle" | "loading" | "done";
const labels: Record<Status, string> = {
idle: "待开始",
loading: "进行中",
done: "已完成",
}; // 少写任何一个键都会编译报错4.7 ReturnType<F>:提取函数返回类型
function createState() {
return {
todos: [] as Todo[],
filter: "all" as "all" | "active",
};
}
type AppState = ReturnType<typeof createState>;
// 直接复用函数体推断出的类型,避免再手写一遍 interface4.8 Parameters<F>:提取函数参数元组
function saveUser(id: number, name: string, tags: string[]) {}
type SaveArgs = Parameters<typeof saveUser>; // [number, string, string[]]
const args: SaveArgs = [1, "张三", ["vip"]];
saveUser(...args); // 展开传入,类型完全对齐记忆策略:改形状的(Partial/Required/Readonly)、裁剪的(Pick/Omit)、组字典的(Record)、反射函数的(ReturnType/Parameters)。它们都是”类型层面的函数”,与 Java 变量 世界里的泛型容器思想一脉相承,只是发生在编译期。
5. 类型断言 as 与 as const
5.1 as:告诉编译器”听我的”
断言是开发者向编译器做出的类型担保,不做任何运行时转换:
const el = document.querySelector("#input") as HTMLInputElement;
el.value = "x"; // Element 上没有 value,断言后可用
// 从 JSON 解析出的数据:编译器只知道它是 any/unknown
const raw = JSON.parse(text) as { users: { id: number }[] };危险警示:断言错了编译器不会救你,运行时照样炸。规范:
- 优先用类型收窄代替断言(上一节的手段)
- 断言仅用于”我知道编译器不知道的信息”,如 DOM API、JSON 边界
- 禁止
as any一把梭——那是自废武功
5.2 as const:深度只读 + 最窄字面量推断
// 默认推断:{ method: string; url: string }
const req = { method: "POST", url: "/api" };
// as const 后:method 的类型是字面量 "POST" 而非 string
const req2 = { method: "POST", url: "/api" } as const;
// 经典应用:替代 enum 的常量映射
const FILTERS = ["all", "active", "done"] as const;
type Filter = typeof FILTERS[number]; // "all" | "active" | "done"as const 把数组变成只读元组、对象属性全部只读且类型收窄到字面量级别,typeof X[number] 从数组值生成联合类型——单一数据源,一行搞定。
6. 声明文件 .d.ts 概念
.d.ts 文件只包含类型声明、不含实现,作用是给纯 JS 库补上类型信息:
// types/my-lib.d.ts —— 给一个无类型的 JS 库手写声明
declare module "legacy-widget" {
export interface WidgetOptions {
width: number;
height: number;
}
export function init(options: WidgetOptions): void;
}实际开发中多数流行库的类型由 DefinitelyTyped 社区维护,安装即可:
npm install --save-dev @types/lodash若导入某库时 IDE 报”隐式 any”,先搜 @types/<包名>,找不到再考虑自己写 .d.ts。
7. 实战:给 fetch 封装加完整泛型类型
目标:封装一个 request<T>,输入请求配置,输出强类型的响应数据。这是几乎所有前端项目的网络层雏形。
// api/request.ts
interface ApiResponse<T> { // 统一响应外壳(与后端约定)
code: number; // 0 表示业务成功
message: string;
data: T;
}
interface RequestOptions extends Omit<RequestInit, "body"> {
query?: Record<string, string | number>;
}
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { query, ...init } = options;
const qs = query
? "?" + new URLSearchParams(
Object.entries(query).map(([k, v]) => [k, String(v)])
).toString()
: "";
const resp = await fetch(url + qs, init);
if (!resp.ok) {
throw new HttpError(resp.status, `HTTP ${resp.status}: ${url}`);
}
const body = (await resp.json()) as ApiResponse<T>;
if (body.code !== 0) {
throw new Error(body.message || "业务错误");
}
return body.data; // 返回类型正是调用方指定的 T
}
export { request, HttpError };
export type { ApiResponse, RequestOptions };调用侧体验:
// api/todo.ts
import { request } from "./request";
interface Todo {
id: number;
title: string;
done: boolean;
}
// 每个 API 函数只需一句话,data 类型自动流转到调用方
const getTodos = () => request<Todo[]>("/api/todos");
const createTodo = (body: Omit<Todo, "id" | "done">) =>
request<Todo>("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 业务代码中获得全程类型保护
async function main() {
const todos = await getTodos(); // todos: Todo[]
const first = todos[0];
console.log(first.title.toUpperCase()); // 全程无 any,补全精准
await createTodo({ title: "复习泛型" }); // 入参同样被约束
}技术要点回顾:
- 三层类型各司其职:ApiResponse<T> 描述外壳、T 描述载荷、HttpError 描述异常路径
- JSON 边界处的一次
as ApiResponse<T>是合理断言(编译器无从知晓网络内容);更严格的方案是运行时校验守卫,见 TS 实战 Omit<RequestInit,...>展示了工具类型组合复用 DOM 内置类型的用法
8. 本章小结
- 字面量联合建模互斥状态,是最值得养成的第一个 TS 习惯。
- 可辨识联合 + switch + never 穷尽检查 = 编译期的完备性证明。
- 泛型语法与 Java 高度同源;extends 约束 + keyof 构成类型级编程的地基。
- 八个工具类型按”改形/裁剪/组字典/反射”四类记忆。
- as 是担保不是转换,能用收窄就不用断言;as const 从值生成最窄类型。
- 缺类型的第三方库靠 @types 或自写 .d.ts 补齐。
- 网络层封装是泛型价值的最佳展示窗口。
下一章回到面向对象:TS 与面向对象。