TypeScript入门

张玥 2026年9月20日 阅读时间 50分钟
TypeScript 类型系统 泛型 工程化 最佳实践
前端开发技巧之TypeScript入门

很多前端同学对 TypeScript 的感情是复杂的:一方面,招聘要求里几乎人手一条“熟悉 TypeScript”;另一方面,真到了写业务的时候,any 一贴、as 一甩,类型检查器瞬间变成了一个昂贵的摆设。问题通常不在于“学不会语法”,而在于没有建立起类型系统的思维模型——不知道类型从哪里来、往哪里去,不知道什么时候该写、什么时候该让编译器自己推。本篇 50 分钟长文,从类型系统的本质讲起,沿着基础类型 → 类型收窄 → 接口与泛型 → 类型编程 → 工程配置 → 渐进迁移这条主线,把 TypeScript 真正用起来所需要的东西完整地过一遍。读完之后,你不一定马上能写出库级别的类型体操,但你一定能做到:让类型成为帮手,而不是负担。

一、为什么前端需要 TypeScript

先看一段几乎所有前端都写过的代码:

// 用户信息展示
function getDisplayName(user) {
  return user.profile.name.toUpperCase();
}

// 某处调用
getDisplayName({ id: 1, name: 'Alice' });
// 💥 TypeError: Cannot read properties of undefined (reading 'name')

这个错误有两个特点:第一,它发生在运行时;第二,它发生在离错误源头很远的地方。写这段代码的人和调用这段代码的人,可能根本不是同一个人,中间隔着三个月和两个版本迭代。JavaScript 的动态类型机制让这一切畅通无阻,直到用户在页面上点了一下,白屏了。

TypeScript 要解决的就是这个问题:把这类错误从“用户运行时”提前到“你按下保存的那一刻”。它不改变 JavaScript 的运行时行为,只是在代码进入浏览器之前,先让一个静态分析器把结构性的错误挑出来。

类型错误的成本曲线

发现阶段 发现者 修复成本 影响范围
编辑器里 你自己 几秒钟 一行代码
代码评审 同事 几分钟 一个 PR
测试环境 测试同学 半小时 一个功能
线上监控 运维 / 用户 几小时起 一批用户
用户投诉 客服 按天计算 品牌与信任

这张表是 TypeScript 全部价值的来源:它把错误往成本曲线的左端推。越往左,同样的错误越便宜。

类型系统的四个维度

要理解 TypeScript 的定位,得先知道“类型系统”其实有四个独立的维度。点击下面的卡片展开说明:

1
检查时机
静态 / 动态
静态:类型在代码运行前就能确定,编译器可以离线分析(TypeScript、Java、Rust)。动态:类型只有运行时才存在(JavaScript、Python、Ruby)。静态检查能提前发现错误,代价是需要写更多声明。
2
严格程度
强 / 弱
强类型:不同类型之间不会自动转换,1 + '1' 会报错。弱类型:允许隐式转换,1 + '1' 得到 '11'。JavaScript 属于弱类型,TypeScript 在编译期加上了强约束,但运行时的弱转换依然存在。
3
等价判断
名义 / 结构
名义类型:必须显式声明“我实现了这个接口”才算兼容(Java、C#)。结构类型:只要形状一样就兼容(TypeScript)。这也是为什么 TS 里两个互不相干的接口,只要字段一致就可以互相赋值。
4
类型来源
显式 / 推断
显式:每个变量都要写类型标注(早期 Java)。推断:编译器根据初始值自动推导(TypeScript、Kotlin)。推断能力越强,需要手写的类型就越少——这是 TypeScript 相比老牌静态语言最友好的地方。

TypeScript 的位置可以一句话概括:静态、编译期强约束、结构类型、强推断。理解了这四点,很多“为什么 TS 是这样设计的”问题就迎刃而解了。

TypeScript 不是什么

  • 不是运行时保障。类型在编译后被完全擦除,浏览器拿到的还是普通 JavaScript。类型系统保护的是“你写代码的过程”,不是“用户使用产品的过程”。所以接口返回的数据该校验还是要校验。
  • 不是万能的。一个 any 就能让整条类型链断掉;一次不安全的 as 断言就能让编译器闭嘴。类型系统的强度,取决于团队愿意维持到什么程度。
  • 不是 Java。不要因为它有 class、interface 就照搬面向对象那一套。TypeScript 的类型是给 JavaScript 服务的,很多场景下用联合类型 + 收窄比用继承清晰得多。
  • 不是只有大项目才需要。小项目同样会遇到“改了一个字段名,忘了改另一个文件”的问题,只是规模小、暴露得晚一点而已。

什么时候值得上 TypeScript

收益明显的场景
  • 多人协作,接口边界多,改一处牵动多处
  • 项目生命周期超过半年,需要长期维护
  • 核心业务逻辑复杂,状态组合多
  • 要对外提供 SDK 或组件库,需要类型提示
  • 团队已经踩过“字段名写错”的坑
收益有限的场景
  • 一次性脚本、临时 Demo、验证想法
  • 团队里没人愿意维护类型定义,最后满地 any
  • 没有构建流程,直接靠 <script> 引入
  • 项目即将下线,只做少量修补

二、从注解到推断:类型的两种来源

TypeScript 里的类型信息有两个来源:你手写的类型注解,和编译器自动推导的类型。初学者最常见的误区是“什么都要标一遍”,结果代码里充斥着冗余的类型声明,改起来比 JavaScript 还累。

// ❌ 冗余:右边已经写得很清楚了
const name: string = 'Alice';
const count: number = 0;
const isActive: boolean = true;
const list: string[] = ['a', 'b'];

// ✅ 让推断干活
const name = 'Alice';
const count = 0;
const isActive = true;
const list = ['a', 'b'];
// ✅ 该写的地方:函数边界
function greet(name: string): string {
  return `Hello, ${name}`;
}

// ✅ 该写的地方:对象的显式契约
interface User {
  id: number;
  name: string;
  email?: string;
}

// ✅ 该写的地方:空数组 / null 初始值
const items: Item[] = [];

一条实用的经验法则:类型注解写在“边界”上。函数参数、函数返回值、模块导出的对象、类的公开成员——这些是别人依赖你的地方。函数体内部的局部变量,绝大多数时候让编译器推断就好。

2.1 基础类型一览

// 原始类型
let s: string = '文本';
let n: number = 42; // 整数、小数、NaN、Infinity 都算
let b: boolean = true;
let big: bigint = 9007199254740991n;
let sym: symbol = Symbol('key');
let u: undefined = undefined;
let nul: null = null;

// 数组:两种写法等价
let arr1: number[] = [1, 2, 3];
let arr2: Array<string> = ['a', 'b'];
let arr3: readonly number[] = [1, 2]; // 只读数组

// 元组:长度与类型都固定的数组
let point: [number, number] = [10, 20];
let entry: [string, number, boolean?] = ['a', 1];

// 对象类型
let user: { id: number; name: string; email?: string } = {
  id: 1,
  name: 'Alice',
};

// 函数类型
let handler: (event: MouseEvent) => void;
let compare: (a: number, b: number) => number = (a, b) => a - b;

2.2 any、unknown、never、void:四个容易搞混的类型

类型 含义 能赋值给别人吗 别人能赋值给它吗 建议
any 放弃检查 能 能 尽量避免,是类型系统的漏洞
unknown 未知但安全 不能 能 外部输入的首选类型
never 不可能出现的值 能 不能 穷尽性检查、抛错函数
void 函数没有返回值 部分 能 回调、事件处理器

unknown 是 any 的安全替代品。它同样能接收任何值,但在使用之前必须先收窄类型:

// ❌ any:编译器完全不拦你
function bad(data: any) {
  return data.user.name; // 运行时可能直接崩
}

// ✅ unknown:必须先证明它是什么
function good(data: unknown) {
  if (typeof data !== 'object' || data === null) {
    throw new Error('期望一个对象');
  }
  if (!('user' in data)) {
    throw new Error('缺少 user 字段');
  }
  return (data as { user: { name: string } }).user.name;
}

never 最常见的用途是穷尽性检查:当你把所有可能的分支都处理完,剩下的那个变量类型会自动变成 never。如果哪天有人往联合类型里加了一个新成员,编译器就会在 never 那一行报错,提醒你“还有分支没处理”。

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'rect':
      return shape.width * shape.height;
    default: {
      // 如果漏了某个分支,这里会编译报错
      const exhaustive: never = shape;
      throw new Error(`未处理的形状:${exhaustive}`);
    }
  }
}

2.3 字面量类型与 as const

TypeScript 允许把具体的值当成类型使用,这是构建联合类型的基础:

type Direction = 'up' | 'down' | 'left' | 'right';
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;

let dir: Direction = 'up';
dir = 'north'; // ❌ 类型错误

// as const 把整个对象变成只读的字面量类型
const config = {
  host: 'localhost',
  port: 8080,
  protocol: 'http',
} as const;

// config.host 的类型是 'localhost',不是 string
// config.port 的类型是 8080,不是 number
// 整个对象是 readonly 的,无法修改

// 经典用法:从常量数组推导联合类型
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];
// 等价于 type Role = 'admin' | 'editor' | 'viewer'

(typeof ROLES)[number] 这个写法值得记住:它是“从值反推类型”的标准姿势。以后要新增角色,只需要往数组里加一项,类型自动更新,再也不用维护两个地方。

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

联合类型是 TypeScript 最强大的特性之一,但它带来一个问题:string | number 上面既没有 padStart 也没有 toFixed,因为编译器不确定你现在拿到的是哪一种。解决办法是收窄(Narrowing)——用运行时的检查,让编译器在某个分支里确定类型。

点击下面的按钮,看看各种收窄方式的实际效果:

function pad(value: string | number) {
  if (typeof value === 'string') {
    // 此分支内 value: string
    return value.padStart(2, '0');
  }
  // 此分支内 value: number
  return value.toFixed(2);
}

typeof 能区分 string、number、boolean、symbol、bigint、undefined、function 和 object。注意 typeof null === 'object' 这个历史遗留问题,判断对象前一定要先排除 null。

function format(value: Date | string) {
  if (value instanceof Date) {
    // 此分支内 value: Date
    return value.toISOString();
  }
  // 此分支内 value: string
  return value.trim();
}

instanceof 依赖原型链,适合类实例的判断。它无法区分接口和类型别名,因为那些在运行时根本不存在。Array.isArray() 则是判断数组的推荐方式。

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    animal.swim(); // animal: Fish
  } else {
    animal.fly(); // animal: Bird
  }
}

in 判断属性是否存在,适合区分“形状不同”的对象类型。它的前提是两个类型没有共同的属性名,否则无法有效区分。

// 每个成员都有一个共同的、取值唯一的字段
type Result<T> =
  | { status: 'ok'; data: T }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function render<T>(result: Result<T>) {
  switch (result.status) {
    case 'ok': return result.data;
    case 'error': return result.message;
    case 'loading': return null;
  }
}

判别联合(Discriminated Union)是 TypeScript 里最重要的建模手法之一。它的核心是给每个成员加一个取值唯一的字面量字段,然后通过这个字段做 switch。状态机、API 响应、事件对象、Redux Action,全都适合用这种方式表达。

function greet(name?: string | null) {
  if (name) {
    // 此分支内 name: string(排除了 undefined 和 null)
    return `Hello, ${name.toUpperCase()}`;
  }
  return 'Hello, guest';
}

// ⚠️ 注意:真值收窄会一并排除 ''、0、false
function bad(count?: number) {
  if (count) {
    // count = 0 时不会进入这里!
    return count + 1;
  }
  return 0;
}

真值收窄很简洁,但会“误伤” 0、''、false 这些合法值。当这些值在你的业务里是有效数据时,应该显式写 count !== undefined,而不是依赖真值判断。

// 自定义类型谓词:返回 x is T
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

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

// 类型谓词可以直接用在 filter 里,自动收窄数组元素类型
const mixed: (string | number)[] = ['a', 1, 'b'];
const onlyStrings = mixed.filter(isString);
// onlyStrings 的类型是 string[],而不是 (string | number)[]

类型谓词是处理外部数据(接口响应、JSON.parse 结果、localStorage 内容)的利器。它的价值不只是“骗过编译器”,而是把校验逻辑集中到一个可复用的函数里——校验和类型声明同步维护,不会脱节。

断言函数:另一种收窄方式

类型谓词返回布尔值,断言函数则直接抛错,适合“不满足条件就中断”的场景:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new TypeError(`期望 string,实际得到 ${typeof value}`);
  }
}

function process(input: unknown) {
  assertIsString(input);
  // 从这里开始,input 就是 string 了
  return input.trim();
}

收窄方式的适用场景

方式 适用 注意
typeof 原始类型、函数 typeof null === 'object'
instanceof 类实例、内置对象 跨 iframe 会失效
in 形状不同的对象 需要没有同名属性
判别字段 联合类型建模 字段值必须唯一
真值判断 排除 null / undefined 会误伤 0、''、false
类型谓词 复杂对象校验 逻辑要真的正确,编译器不会验证
断言函数 参数校验、守卫 会抛异常,注意调用位置

四、interface 与 type:两种声明方式的分工

这是初学者最常问的问题之一:“到底该用 interface 还是 type?”简短的回答是:大部分场景下两者可以互换,但有几处关键差异决定了选择。

// interface:描述对象形状
interface User {
  id: number;
  name: string;
  email?: string;
  readonly createdAt: Date;
}

// 声明合并:同名 interface 会自动合并
interface User {
  avatar?: string;
}

// 最终 User 拥有 id/name/email/createdAt/avatar
// type:什么都能定义
type ID = string | number;
type Point = [number, number];
type Handler = (e: Event) => void;
type Nullable<T> = T | null;

// 组合与运算
type UserWithRole = User & { role: 'admin' | 'user' };
type Keys = keyof User;
能力 interface type
描述对象形状 可以 可以
描述联合类型 不行 可以
描述元组 不行 可以
声明合并 可以 不行
被类 implements 可以 可以(对象形状)
extends / 交叉 extends &
条件类型中使用 受限 自由
错误提示可读性 显示名字 可能展开

选择建议

选 interface 描述对象、类的契约,需要被 implements,需要对外暴露名字给第三方扩展
选 type 联合类型、元组、函数类型、需要做类型运算(条件 / 映射 / 模板字面量)
别纠结 两者都能表达的对象形状,团队统一风格即可
避免 同一个名字同时用 interface 和 type 声明,容易让人困惑

对象类型的进阶写法

// 索引签名:任意字符串键
interface StringMap {
  [key: string]: string;
}

// 同时限定已知键和未知键
interface Headers {
  contentType: string;
  authorization?: string;
  [key: string]: string | undefined;
}

// 只读数组与只读元组
type ImmutableList = readonly string[];
type Pair = readonly [number, number];

// 函数重载:同一个函数多种签名
function createElement(tag: 'input'): HTMLInputElement;
function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: string): HTMLElement;
function createElement(tag: string): HTMLElement {
  return document.createElement(tag);
}

注意重载的写法的两个要点:重载签名在前,实现签名在后,而且实现签名必须兼容所有重载签名。调用方只能看到重载签名,看不到实现签名。

五、泛型:让类型也能当参数

泛型是 TypeScript 从“给代码加标签”升级到“可复用的类型逻辑”的分水岭。它的本质是:把类型本身变成一个参数,让调用方决定具体是什么。

// ❌ 不用泛型:要么丢类型,要么写很多遍
function firstAny(arr: any[]): any {
  return arr[0];
}

// ✅ 用泛型:类型随输入自动推导
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const a = first([1, 2, 3]); // number | undefined
const b = first(['x', 'y']); // string | undefined
const c = first<boolean>([]); // 也可以显式指定

5.1 泛型约束:限制 T 的范围

裸的 T 什么属性都访问不了。如果函数体里需要用到某个属性,就必须告诉编译器“T 至少要有这些”:

// ❌ 报错:T 上不存在 length
function longest<T>(a: T, b: T) {
  return a.length > b.length ? a : b;
}

// ✅ 用 extends 约束
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}

longest('hello', 'hi'); // string
longest([1, 2, 3], [1]); // number[]
longest(1, 2); // ❌ number 没有 length

// keyof 约束:K 必须是 T 的键
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Alice' };
get(user, 'name'); // string
get(user, 'id'); // number
get(user, 'age'); // ❌ 'age' 不在 keyof typeof user 中

get 这个函数是 TypeScript 类型推导的一个小高峰:返回值类型 T[K] 会根据传入的 key 自动变化。传 'name' 得到 string,传 'id' 得到 number。这种“类型跟着值走”的能力,是让 API 变得好用又安全的关键。

5.2 默认类型参数

interface ApiResponse<T = unknown, E = string> {
  success: boolean;
  data: T;
  error: E | null;
}

type A = ApiResponse; // data: unknown
type B = ApiResponse<User>; // data: User
type C = ApiResponse<User, ErrorCode>; // 两个都指定

5.3 泛型在函数式工具中的应用

// 用泛型给常用工具函数加上类型
function groupBy<T, K extends string | number>(
  list: T[],
  getKey: (item: T) => K,
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of list) {
    const key = getKey(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders = [
  { id: 1, status: 'paid', amount: 100 },
  { id: 2, status: 'pending', amount: 200 },
  { id: 3, status: 'paid', amount: 300 },
];

const byStatus = groupBy(orders, (o) => o.status);
// Record<'paid' | 'pending', Order[]>
// 注意:K 被推导成了字面量联合类型,而不是宽泛的 string

这里有一个细节值得留意:因为 orders 是 const 声明、且 status 字段被推导为字面量联合类型,所以 K 最终是 'paid' | 'pending' 而不是 string。这意味着 byStatus.paid 是合法的,而 byStatus.refunded 会报错——类型把业务事实也编码进去了。

5.4 泛型的常见误区

只出现一次的泛型
function log<T>(value: T): void —— T 只出现一次,没有建立任何关系,等价于 function log(value: unknown): void。泛型的意义在于建立输入与输出之间的关系。
有来有回的泛型
function identity<T>(value: T): T —— 输入什么类型,输出就是什么类型。或者 function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>,输入决定输出。
泛型参数名随意
<A, B, C, D> 五个单字母参数,三个月后没人记得谁是谁。单个参数用 T 没问题,多个参数应该用有意义的名字。
有语义的命名
<TData, TError>、<TInput, TOutput>、<TKey, TValue>。读签名就能知道每个类型参数代表什么。

六、内置工具类型:不用重复造轮子

TypeScript 标准库自带了一批工具类型(Utility Types),它们本身就是用条件类型和映射类型实现的。熟练使用它们,可以省掉大量重复定义。

工具类型 作用 典型用法
Partial<T> 所有属性变可选 更新接口的入参
Required<T> 所有属性变必填 校验完成后的数据
Readonly<T> 所有属性只读 常量配置、冻结状态
Pick<T, K> 挑选部分属性 列表项只暴露少数字段
Omit<T, K> 排除部分属性 创建时去掉 id
Record<K, V> 构造键值映射 枚举到文案的映射表
Exclude<T, U> 从联合中排除 剔除某些字面量
Extract<T, U> 从联合中提取 只保留函数类型
NonNullable<T> 去掉 null / undefined 校验后的数据
ReturnType<F> 取函数返回值类型 复用已有函数的返回类型
Parameters<F> 取函数参数类型元组 包装函数时转发参数
Awaited<T> 递归解开 Promise 取 async 函数的实际返回类型

实战:一个完整的用户模块类型定义

// 基础实体
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: string;
  updatedAt: string;
}

// 创建:去掉服务端生成的字段
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;

// 更新:全部可选,但不允许改 id
type UpdateUserInput = Partial<Omit<User, 'id'>>;

// 列表项:只暴露列表需要的字段
type UserListItem = Pick<User, 'id' | 'name' | 'role'>;

// 角色文案映射:键必须覆盖所有角色,值必须是字符串
const ROLE_LABEL: Record<User['role'], string> = {
  admin: '管理员',
  editor: '编辑',
  viewer: '访客',
  // 如果 User 新增了角色,这里会立刻报错 —— 这正是我们想要的
};

// 从已有函数推导类型,避免重复声明
async function fetchUser(id: number): Promise<User> { ... }
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// FetchedUser 就是 User

ROLE_LABEL 这个例子值得反复体会:用 Record<User['role'], string> 声明映射表,等于把“角色列表”和“文案表”绑在了一起。以后新增角色,如果忘了加文案,编译器会直接报错。这就是类型系统在“防遗漏”上的价值。

自定义工具类型

// 把指定字段变成可选,其余保持原样
type Optional<T, K extends keyof T> =
  Omit<T, K> & Partial<Pick<T, K>>;

// 把指定字段变成必填
type RequireFields<T, K extends keyof T> =
  T & Required<Pick<T, K>>;

// 深层只读
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K];
};

// 取出数组中元素的类型
type ElementOf<T extends readonly unknown[]> = T[number];

// 用法
type Users = User[];
type SingleUser = ElementOf<Users>; // User

七、条件类型、映射类型与模板字面量类型

如果说泛型是“类型可以当参数”,那么这一节讲的就是“类型可以参与运算”。这是 TypeScript 类型系统最灵活、也最容易失控的部分。

7.1 条件类型

条件类型的语法和三元表达式一样: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

// 取出 Promise 的内部类型(简化版 Awaited)
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type C = Unwrap<Promise<number>>; // number
type D = Unwrap<string>; // string

7.2 infer:在条件类型里“提取”类型

infer 是条件类型的关键字,它允许你在模式匹配中声明一个待推导的类型变量:

// 提取函数返回值
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;

// 提取函数第一个参数
type FirstParam<T> = T extends (first: infer F, ...rest: never[]) => unknown ? F : never;

// 提取数组元素
type ItemType<T>> = T extends (infer I)[] ? I : never;

// 提取对象某个属性的类型
type PropType<T, K> = K extends keyof T ? T[K] : never;

// 提取数组最后一个元素
type Last<T extends unknown[]> =
  T extends [...unknown[], infer L] ? L : never;

type E = Last<[string, number, boolean]>; // boolean

7.3 分发式条件类型

当条件类型作用在裸类型参数上、且传入的是联合类型时,它会自动分发到每个成员:

type ToArray<T> = T extends unknown ? T[] : never;

type R1 = ToArray<string | number>;
// 分发后:string[] | number[]
// 而不是:(string | number)[]

// 阻止分发:用方括号包起来
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
type R2 = ToArrayNoDist<string | number>;
// (string | number)[]

// 实用例子:只保留字符串键
type StringKeys<T> = {
  [K in keyof T]: K extends string ? K : never;
}[keyof T];

分发行为是很多内置工具类型的实现基础,比如 Exclude<T, U> 的定义就是 T extends U ? never : T——正因为分发的存在,它才能逐个过滤联合类型中的成员。

7.4 映射类型

映射类型让你“遍历一个类型的所有属性,逐个变换”:

// 把每个属性变成 getter 形式
type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

interface User { id: number; name: string }

type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }

// 去掉所有可选修饰符
type Concrete<T> = {
  [K in keyof T]-?: T[K];
};

// 去掉所有只读修饰符
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// 从事件定义生成处理器类型
interface Events {
  click: { x: number; y: number };
  change: { value: string };
}

type Handlers = {
  [K in keyof Events as `on${Capitalize<K & string>}`]: (payload: Events[K]) => void;
};
// { onClick: (payload: {x, y}) => void; onChange: (payload: {value}) => void }

as 子句(键重映射)是 TypeScript 4.1 引入的能力,它让映射类型可以对键本身做变换。Capitalize、Uppercase、Lowercase、Uncapitalize 是四个内置的字符串操作工具类型。

7.5 模板字面量类型

这是类型系统里最“魔法”的部分:类型层面也能做字符串拼接。它让 API 的类型定义可以精确到字符串的内容。

type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

// 解析路由参数
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<'/user/:userId/post/:postId'>;
// 'userId' | 'postId'

// 类型安全的 CSS 单位
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vh' | 'vw';
type Length = `${number}${CSSUnit}` | 'auto' | '0';

const width: Length = '100px'; // ✅
const height: Length = '100'; // ❌ 缺少单位
const margin: Length = '2rem'; // ✅
模板字面量类型的合适场景
  • 事件名、路由、i18n key 这类有固定格式的字符串
  • CSS 单位、颜色、尺寸等需要约束格式的值
  • 从已有类型生成派生类型(如 getter 名)
应该克制的场景
  • 为了炫技而写三层嵌套的字符串解析
  • 把类型报错信息变得完全无法阅读
  • 编译器明显变慢(类型体操是编译时的开销)

八、函数、异步与错误处理

8.1 函数类型的完整写法

// 可选参数与默认值
function request(
  url: string,
  method: 'GET' | 'POST' = 'GET',
  timeout?: number,
): Promise<unknown> { ... }

// 剩余参数
function log(level: 'info' | 'warn', ...messages: unknown[]) { ... }

// 解构参数的类型
function createUser({
  name,
  age = 0,
  email,
}: {
  name: string;
  age?: number;
  email?: string;
}) { ... }

// 函数类型别名,便于复用
type Comparator<T> = (a: T, b: T) => number;
const byAge: Comparator<User> = (a, b) => a.age - b.age;

注意一个细节:可选参数必须排在必填参数后面。如果确实需要“中间可选”,应该显式传入 undefined,或者改用对象参数——后者在参数超过三个时通常更清晰。

8.2 回调与 this 类型

// 显式声明 this 类型
function handleClick(this: HTMLElement, event: MouseEvent) {
  // this 被声明为 HTMLElement
  this.classList.add('active');
}

// 类字段箭头函数:this 永远指向实例
class Counter {
  count = 0;
  increment = () => { this.count++; };
}

// 泛型 this 类型:支持链式调用并保留子类类型
class Builder {
  private parts: string[] = [];

  add(part: string): this {
    this.parts.push(part);
    return this;
  }

  build(): string {
    return this.parts.join('-');
  }
}

class UrlBuilder extends Builder {
  protocol(p: string): this {
    this.parts.unshift(p + '://');
    return this;
  }
}

// 用 this 类型,链式调用不会丢失子类信息
new UrlBuilder().protocol('https').add('example.com').build();
// .protocol 之后返回的仍是 UrlBuilder,而不是 Builder

8.3 异步与 Promise 类型

// async 函数隐式返回 Promise
async function loadUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

// Promise.all 保留元组结构
const [user, posts] = await Promise.all([
  loadUser(1), // Promise<User>
  loadPosts(1), // Promise<Post[]>
]);
// user: User, posts: Post[] —— 自动推导,不需要手动断言

// Promise.allSettled 的结果类型
const results = await Promise.allSettled([loadUser(1)]);
for (const r of results) {
  if (r.status === 'fulfilled') {
    console.log(r.value); // User
  } else {
    console.error(r.reason); // any
  }
}

8.4 错误处理中的 unknown

在 strict 模式下,catch 到的变量类型是 unknown(如果显式标注为 any 或 unknown,默认在 useUnknownInCatchVariables 开启时是 unknown)。这意味着你不能直接访问它的属性——这是好事,因为抛出的东西真的可能是任何值。

// ❌ 直接访问会报错
try {
  await saveData();
} catch (err) {
  console.log(err.message); // ❌ err 是 unknown
}

// ✅ 先收窄,再使用
function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  if (typeof value === 'string') return new Error(value);
  return new Error('未知错误');
}

try {
  await saveData();
} catch (err) {
  const error = toError(err);
  console.error(error.message);
}

8.5 结果类型:让错误进入类型系统

另一种常见的风格是不抛异常,而是返回一个显式的结果对象。这样“可能失败”就成了类型的一部分,调用方必须处理:

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function safeLoad(id: number): Promise<Result<User>> {
  try {
    const user = await loadUser(id);
    return { ok: true, value: user };
  } catch (err) {
    return { ok: false, error: toError(err).message };
  }
}

const result = await safeLoad(1);
if (result.ok) {
  console.log(result.value.name); // 收窄为 User
} else {
  console.error(result.error); // 收窄为 string
}

这种风格的优点是错误不可能被静默忽略——因为 result.ok 是 false 时根本访问不到 value。代价是每一层都要多写几行包装代码。它适合错误是“预期内业务分支”的场景,而不是真正的异常。

九、类、修饰符与面向对象

TypeScript 给 JavaScript 的 class 加上了访问修饰符、抽象类、参数属性等能力。但要提醒一句:不要因为有了这些工具就把前端代码写成 Java。组合通常优于继承,函数通常优于类。

9.1 访问修饰符

class BankAccount {
  public owner: string; // 默认,谁都能访问
  private balance: number = 0; // 只有类内部能访问
  protected currency = 'CNY'; // 类与子类可访问
  readonly openedAt = new Date(); // 只能在构造函数中赋值

  // 真正的私有字段(ECMAScript 标准)
  #secret = 'internal';

  // 参数属性:把参数声明和赋值合并
  constructor(
    public id: number,
    private name: string,
  ) {
    this.owner = name;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  get currentBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount(1, 'Alice');
account.deposit(100);
account.currentBalance; // 100
account.balance; // ❌ private

private 和 # 的区别值得说清楚:private 只在类型检查阶段生效,编译后的 JavaScript 里这个字段依然是公开的;而 # 是真正的运行时私有,编译产物里也会保持私有。如果只是想让类型系统拦住误用,private 就够了;如果涉及安全敏感的数据,应该用 #。

9.2 抽象类与接口实现

// 抽象类:定义骨架,具体步骤留给子类
abstract class StorageAdapter {
  abstract read(key: string): Promise<string | null>;
  abstract write(key: string, value: string): Promise<void>;

  // 通用逻辑写在基类,子类只需要实现读写
  async readJSON<T>(key: string): Promise<T | null> {
    const raw = await this.read(key);
    return raw ? JSON.parse(raw) : null;
  }

  async writeJSON<T>(key: string, value: T): Promise<void> {
    await this.write(key, JSON.stringify(value));
  }
}

class LocalStorageAdapter extends StorageAdapter {
  async read(key: string) {
    return localStorage.getItem(key);
  }
  async write(key: string, value: string) {
    localStorage.setItem(key, value);
  }
}

// 注意:抽象类不能被实例化
new StorageAdapter(); // ❌

9.3 接口实现与结构化类型

TypeScript 是结构类型系统,这意味着一个类不需要显式声明“我实现了某个接口”,只要形状对得上就行。这在包装第三方对象时特别有用,但也会带来一些意外:

interface Point { x: number; y: number }
interface Named { name: string }

function draw(point: Point) { ... }

// 多出来的字段在“直接传入字面量”时会报错
draw({ x: 1, y: 2, z: 3 }); // ❌ 对象字面量多余属性检查

// 但通过变量传入就不会
const p = { x: 1, y: 2, z: 3 };
draw(p); // ✅ 合法

// 空接口 / 空对象类型会接受任何非空值,慎用
function acceptAnything(value: {}) { ... }
acceptAnything('string'); // ✅ 能通过

// 真正的“任何非 null / undefined 值”应该用 unknown 或 object

9.4 声明合并

接口可以同名合并,这在扩展第三方类型时非常有用:

// 给 Window 增加自定义属性
declare global {
  interface Window {
    __APP_VERSION__: string;
    __DEV__: boolean;
    dataLayer: unknown[];
  }
}

// 扩展第三方库的类型
declare module 'express' {
  interface Request {
    user?: { id: string; role: string };
  }
}

9.5 类还是函数?

适合用类的场景
  • 需要维护内部状态,并且状态与行为强绑定
  • 需要创建多个同类实例
  • 需要实现某个明确的接口(如适配器)
  • 需要利用 private / # 封装不变量
更适合用函数的场景
  • 无状态的数据转换、格式化、校验
  • 只有一两个方法的“工具对象”
  • React 组件(函数组件是主流)
  • 只是想把一组常量放在一起

十、模块、声明文件与第三方库

10.1 导入导出的类型写法

// 只导入类型:编译后会被完全删除,不产生运行时依赖
import type { User, ApiResponse } from './types';

// 混合导入
import { fetchUser, type User } from './api';

// 只导出类型
export type { User };
export type Status = 'idle' | 'loading';

// 三斜线指令(较少用,一般出现在 .d.ts 顶部)
/// <reference types="node" />

// 导入 JSON(需要 resolveJsonModule)
import config from './config.json';

// 动态导入返回 Promise<模块类型>
const { default: Chart } = await import('chart.js');

import type 有一个容易被忽略的好处:它能防止循环依赖。因为纯类型导入在编译后会被删除,所以“A 导入 B 的类型、B 导入 A 的类型”这种循环,在运行时并不会真的形成环。

10.2 声明文件(.d.ts)

当你要为一个没有类型的 JavaScript 库写类型时,就需要声明文件。它的规则是:只写类型,不写实现。

// types/legacy-lib.d.ts
declare module 'legacy-lib' {
  export interface Options {
    debug?: boolean;
    timeout?: number;
  }

  export function init(options?: Options): void;
  export function track(event: string, payload?: Record<string, unknown>): void;

  const _default: { init: typeof init; track: typeof track };
  export default _default;
}

// 声明一个没有类型的模块(逃生舱,不推荐长期使用)
declare module 'untyped-package';

// 声明全局变量
declare const VERSION: string;
declare function gtag(...args: unknown[]): void;

// 声明资源模块(让 import './x.css' 有类型)
declare module '*.css' {
  const classes: Record<string, string>;
  export default classes;
}

declare module '*.svg' {
  const url: string;
  export default url;
}

10.3 @types 与类型来源优先级

当你 import 一个库却报“找不到类型声明”时,TypeScript 会按下面的顺序找类型:

1. 包自带 包的 package.json 里有 types 或 typings 字段,或目录下有 index.d.ts
2. @types 包 社区维护的 @types/xxx,一般通过 npm i -D @types/xxx 安装
3. 自己的声明 项目里的 *.d.ts,通过 typeRoots 或 include 被加载
4. 报错 在 noImplicitAny 下会提示“Could not find a declaration file”

现在大多数主流库(React、Vue、axios、lodash、zod)都已经自带类型,只有历史包袱较重的老库才需要 @types。如果某个库确实没有类型,优先去社区看看有没有 @types 包;实在没有,再考虑自己写声明。

10.4 模块增强与全局增强

// 扩展第三方库的模块类型(放在项目任意 .d.ts 中)
import 'axios';

declare module 'axios' {
  export interface AxiosRequestConfig {
    skipAuth?: boolean;
    traceId?: string;
  }
}

// 扩展 CSS 变量类型(配合模板字面量类型)
declare namespace CSS {
  interface Properties {
    '--brand-color'?: string;
    '--spacing-unit'?: '4px' | '8px' | '12px';
  }
}

十一、tsconfig:把编译器调成你想要的样子

tsconfig.json 是 TypeScript 项目的总控台。下面是一份适用于现代前端项目的配置,每一条都有必要存在的理由。

{
  "compilerOptions": {
    /* ===== 语言与输出 ===== */
    "target": "ES2022", // 输出语法版本
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",

    /* ===== 严格模式(建议全开) ===== */
    "strict": true,
    "noUncheckedIndexedAccess": true, // 索引访问结果自动带上 undefined
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,

    /* ===== 代码质量 ===== */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "allowUnreachableCode": false,

    /* ===== 互操作 ===== */
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,

    /* ===== 生成产物 ===== */
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noEmit": true, // 交给打包器输出,tsc 只做检查

    /* ===== 路径别名 ===== */
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  },
  "include": ["src/**/*", "types/**/*"],
  "exclude": ["node_modules", "dist"]
}

关键的严格选项解释

选项 开启后的效果 建议
strict 一次性打开下面所有严格检查 新项目必开
strictNullChecks null 和 undefined 不再属于所有类型 必开
noImplicitAny 禁止隐式 any 必开
strictFunctionTypes 函数参数逆变检查更严格 必开
noUncheckedIndexedAccess obj[key] 结果带上 undefined 推荐,但改动量大
exactOptionalPropertyTypes age?: number 不允许显式传 undefined 谨慎开启
skipLibCheck 跳过 .d.ts 的类型检查 建议开启(提速)

noUncheckedIndexedAccess 值得单独说一句:开启后,arr[0] 的类型会从 T 变成 T | undefined。这会让很多现有代码报错,但它确实反映了真相——数组索引真的可能越界。如果项目已经稳定,可以分阶段开启;如果正在重构,建议直接开。

多环境配置拆分

// tsconfig.json —— 只放公共配置
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

// 应用代码用一套,构建脚本用另一套
// 好处:Vite 配置文件的 Node 类型不会污染前端代码

十二、从 JavaScript 渐进迁移到 TypeScript

“我们项目三万行 JavaScript,不可能停下来重写一遍。”——这是最现实的顾虑。好消息是,TypeScript 从设计之初就支持渐进式迁移,你完全可以让 .js 和 .ts 长期共存。

第一阶段:只做检查,不改代码

// 1. 安装依赖
npm i -D typescript

// 2. 生成配置
npx tsc --init

// 3. 允许 JS 文件参与编译,但先不做检查
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true
  }
}

// 4. 先只对新增文件做检查
// 在 CI 里跑 tsc --noEmit,失败不阻塞合并,只做提示

第二阶段:逐个文件改名

迁移的顺序很重要。建议从依赖最少的叶子模块开始,逐步往上层推进:

先改 工具函数、常量、纯数据类型定义——没有依赖,改了不会牵连别人
再改 API 请求层、状态管理——它们被很多组件依赖,类型收益最大
然后改 业务组件、页面——改动量大,但收益也最直接
最后改 构建脚本、配置文件——它们和业务代码的耦合最低

第三阶段:逐步收紧

// 不要一上来就开 strict,先跑通再收紧

// 第一步:打开 allowJs + checkJs,暴露问题清单
// 第二步:给高频使用的模块补类型声明
// 第三步:打开 noImplicitAny,消灭隐式 any
// 第四步:打开 strictNullChecks,处理空值分支
// 第五步:打开完整 strict

// 临时逃生舱:只对某个文件放宽
// @ts-nocheck —— 整个文件跳过检查(尽量别用)
// @ts-ignore —— 忽略下一行的错误
// @ts-expect-error —— 期望下一行报错(比 ignore 好,因为错误消失时会提醒你)

@ts-expect-error 比 @ts-ignore 更值得推荐:当底层的 bug 被修复、这行代码不再报错时,@ts-expect-error 会反过来报错提醒你“这个抑制注释已经没用了,删掉它”。它自带清理机制。

第四阶段:让类型成为约束

// package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "npm run typecheck && vite build"
  }
}

// CI 里把它设为必过项,类型错误不允许合并
// 同时配置 lint 规则,禁止新增 any

迁移中的常见阻力与应对

阻力 原因 应对
报错太多,无从下手 一次性开了太多严格选项 分批开启,先跑通构建
第三方库没类型 老库缺少 .d.ts 先写最小声明(declare module 'x'),后续补全
改一处错一片 类型定义不准确,牵连太广 从叶子模块自下而上改
团队不配合 写类型增加了当下的工作量 先在小范围试点,用“少写了多少次调试”说话
到处是 any 赶进度时的妥协 ESLint 加 no-explicit-any,配合 unknown

十三、常见陷阱与反模式

TypeScript 用不好,比不用更糟——因为它会给人一种“已经有类型保护了”的错觉。下面这些坑,几乎每个团队都踩过。

陷阱一:any 的传染

一个 any 会沿着调用链一路传播。你从接口里 any 出来一个值,传给函数 A,A 返回 any,再传给 B……最后整个数据流都失去了类型。

any 蔓延
const data: any = await res.json();
const name = data.user.name;
const upper = name.toUpperCase();
—— 编译期一路绿灯,运行时可能到处是坑。
unknown + 校验
const raw: unknown = await res.json();
if (!isUserResponse(raw)) throw new Error('响应格式错误');
const name = raw.user.name; // 类型安全
—— 校验逻辑可复用,类型与运行时一致。

陷阱二:滥用类型断言

// ❌ 断言是在对编译器说"相信我",而不是"我知道为什么"
const user = data as User;

// ✅ 先校验,再断言
if (isUser(data)) {
  const user = data; // 已经收窄,不需要断言
}

// ⚠️ 双断言绕过检查:几乎总是错的
const n = 'abc' as unknown as number;

// ✅ 如果确实需要断言,用 satisfies 保留检查
const config = {
  host: 'localhost',
  port: 8080,
} satisfies Record<string, string | number>;
// config 保留了字面量类型,同时验证了形状

satisfies 是 TypeScript 4.9 引入的运算符,它解决了一个长期痛点:既要验证对象符合某个类型,又要保留对象本身的精确推断。用 as 会丢掉精确类型,用类型注解会丢掉字面量,satisfies 两者兼得。

陷阱三:非空断言滥用

// ❌ 用 ! 强行告诉编译器"这个值一定存在"
const el = document.getElementById('app')!;
el.innerHTML = '';

// ✅ 显式处理不存在的情况
const el = document.getElementById('app');
if (!el) {
  throw new Error('找不到 #app 容器');
}
el.innerHTML = '';

// ✅ 或者用自定义断言函数集中处理
function requireElement<T extends Element>(selector: string): T {
  const el = document.querySelector<T>(selector);
  if (!el) throw new Error(`找不到元素:${selector}`);
  return el;
}

陷阱四:过度使用枚举

// ⚠️ 数字枚举会生成额外的运行时代码
enum Status {
  Idle,
  Loading,
  Success,
  Error,
}
// 编译后是一个双向映射对象,体积不小

// ✅ 字面量联合类型:零运行时开销
type Status = 'idle' | 'loading' | 'success' | 'error';

// ✅ 需要常量表时用 as const 对象
const STATUS = {
  IDLE: 'idle',
  LOADING: 'loading',
  SUCCESS: 'success',
  ERROR: 'error',
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
// 既有常量表可用,又有联合类型可标注,还不会生成多余代码

陷阱五:类型体操过度

类型体操写起来很爽,但它的代价是:编译变慢、报错信息变得不可读、团队成员看不懂。判断标准很简单:如果一个类型定义需要写注释才能让人看懂,那它可能已经过度了。

难以维护的类型
type DeepMerge<A, B, K extends keyof B = keyof B> = { [P in keyof (A & B)]: P extends keyof A ? P extends keyof B ? A[P] extends object ? B[P] extends object ? DeepMerge<A[P], B[P]> : B[P] : B[P] : A[P] : B[P] }
—— 报错时你会看到一屏的展开信息,完全找不到问题在哪。
够用就好的类型
type MergedConfig = AppConfig & Partial<RuntimeConfig>;
用交叉类型和内置工具类型解决问题,报错信息清晰,任何人都能看懂。

陷阱六:把类型当成运行时校验

// ❌ 类型在运行时不存在,接口返回什么它管不了
interface User { id: number; name: string }
const user: User = await res.json();
// 如果服务端返回的是 { id: '1', name: null },这里不会报错

// ✅ 运行时校验 + 类型推断,两者一致
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email().optional(),
});

type User = z.infer<typeof UserSchema>;
// 类型从 schema 推导,永远不会脱节

const user = UserSchema.parse(await res.json());
// 校验失败会抛错,成功则得到可信的 User

这条是本文最重要的一条建议:凡是从外部进入系统的数据,都应该在运行时校验一次。这些边界包括:HTTP 响应、localStorage、postMessage、URL 参数、表单输入、第三方 SDK 回调。类型系统管不到这些地方,只有运行时代验才能保证数据真的符合预期。

陷阱七:文件命名与模块解析的坑

大小写 macOS 文件系统不区分大小写,import './User' 在本地没问题,到 Linux CI 就炸
扩展名 moduleResolution: "Bundler" 时不用写扩展名,Node 的 ESM 则必须写 .js
循环依赖 类型之间的循环用 import type 打破,值之间的循环需要重构
路径别名 tsconfig 里的 paths 只影响类型检查,打包器需要单独配置

十四、实战:给一个真实的数据模块加上类型

下面是一段典型的前端数据获取代码,用 JavaScript 写成。它的问题很常见:字段名靠记忆、错误处理靠猜、调用方不知道会返回什么。点击按钮对比改造前后的代码。

// api.js —— 全靠约定和记忆
export async function fetchOrders(params) {
  const query = new URLSearchParams();
  if (params.page) query.set('page', params.page);
  if (params.status) query.set('status', params.status);
  if (params.keyword) query.set('q', params.keyword);

  const res = await fetch(`/api/orders?${query}`);
  const json = await res.json();

  // 谁能告诉我 json 长什么样?
  if (json.code === 0) {
    return json.data;
  } else {
    // msg 还是 message?
    throw new Error(json.msg);
  }
}

// 调用方
const data = await fetchOrders({ page: 1, status: 'paid' });
data.list.forEach((order) => {
  // order.amount 还是 order.totalAmount?
  // 编辑器没有任何提示,只能去后端代码里翻
  render(order.id, order.amount);
});

// 问题清单:
// 1. 字段名写错只能运行时发现
// 2. 分页结构的字段无从得知
// 3. status 的合法取值没有约束
// 4. 错误分支返回的 msg 可能是 undefined
// 5. 重构时无法知道哪些地方依赖了哪些字段

改造带来的收益

字段可发现 编辑器自动补全所有字段,不需要去翻后端代码或接口文档
重构可追踪 重命名字段时,IDE 能找出所有引用点,一次改干净
契约可表达 “错误时 data 一定是 null”这种约定,用判别联合精确表达
边界可校验 运行时 schema 校验 + 静态类型,两条防线同时生效
取值可约束 status 只能是四个字面量之一,拼错立刻报错

这个改造的关键思路是:把“数据长什么样”从散落各处的口头约定,变成一个显式定义、集中维护、可被工具读取的东西。类型定义写一次,编辑器补全、编译检查、重构支持、文档生成全都跟着来。

十五、速查表与落地清单

最后把全文浓缩成一份可以贴在工位上的速查表。

你遇到的问题 可以先看看 关键点
函数参数不知道传什么 函数类型标注 在函数边界写注解,函数体内部靠推断
一个值可能是好几种类型 联合类型 + 收窄 typeof / in / 判别字段
同一段逻辑要适用于多种类型 泛型 泛型要建立输入与输出的关系
需要从一个类型派生另一个类型 工具类型 / 映射类型 Pick、Omit、Partial、Record
字符串必须符合某种格式 模板字面量类型 `on${Capitalize<K>}`
接口返回的数据不可信 unknown + 运行时校验 zod / 自定义类型谓词
状态机 / 异步流程建模 判别联合类型 每个状态一个唯一的 status 字段
第三方库没有类型 @types / 自己写 .d.ts 先写最小声明,后续补全
类型报错看不懂 拆解交叉类型 用 type Debug = ... 逐层展开
老项目想上 TS 渐进迁移 allowJs → checkJs → strict,分批推进

落地清单

  • 新项目直接开 strict。从第一天严格,比后期收紧便宜十倍。
  • 类型注解只写在边界上。函数签名、导出对象、类的公开成员。
  • 能推断的不要手写。冗余的类型注解只会增加维护成本。
  • 用 unknown 代替 any。外部数据进来先当成未知,校验之后再收窄。
  • 用判别联合建模状态。比一堆布尔标志位清晰得多。
  • 用 import type 导入纯类型。避免运行时依赖和循环引用。
  • 用 satisfies 而不是 as。既验证形状,又保留精确推断。
  • 用 never 做穷尽性检查。让新增分支时的遗漏在编译期暴露。
  • 外部数据一定要运行时校验。类型系统管不到网络返回和本地存储。
  • 类型体操适可而止。报错信息看不懂的类型,就是在增加团队的负担。
  • 把 tsc --noEmit 放进 CI。否则类型检查迟早会形同虚设。
  • 衡量标准只有一个:改需求时,有多少错误能在保存前被发现?这个数字越大,类型系统越有价值。
// 一份可以常备的"类型工具箱"骨架

// 1. 判别联合 —— 状态建模
type Result<T> =
  | { status: 'ok'; data: T }
  | { status: 'error'; message: string };

// 2. 从常量推导类型 —— 单一事实来源
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];

// 3. 类型安全的属性访问
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// 4. 运行时校验 + 类型推断
function isUser(v: unknown): v is User {
  return typeof v === 'object' && v !== null
    && 'id' in v && 'name' in v;
}

// 5. 穷尽性检查
function assertNever(x: never): never {
  throw new Error(`未处理的分支:${JSON.stringify(x)}`);
}

// 6. 保留推断的校验
const config = {
  apiBase: '/api/v2',
  timeout: 8000,
} satisfies Record<string, string | number>;

TypeScript 学习曲线里最陡的一段,不是语法,而是判断力——知道哪里该写类型、哪里该让推断干活、哪里该用 unknown 挡住风险、哪里该收手不写类型体操。这种判断力只能从实践中来:先写出来,报错了再想为什么,慢慢就会形成直觉。

最后给一个朴素的建议:不要试图一次性学完所有特性。先把基础类型、联合类型、收窄、接口、泛型这五样练熟,它们能覆盖日常开发九成以上的场景。至于条件类型、映射类型、模板字面量类型,等到你真的遇到“需要从一个类型派生出另一个类型”的需求时再回头看,那时你会发现它们其实很自然。

类型系统的终极目标不是“类型写得漂亮”,而是让你在改动代码时心里有底。当编辑器能替你记住每一个字段、每一次调用、每一处依赖的时候,你才能真正把注意力放在业务逻辑本身,而不是花在“这个字段到底叫什么”上。