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>;
// 直接复用函数体推断出的类型,避免再手写一遍 interface

4.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 与面向对象