第 4 章

接口 vs 类型别名:一张决策树

第4章:接口 vs 类型别名:一张决策树

interface 和 type 在实际开发中到底该怎么选?这是理解本章内容的出发点。

本章核心问题:interface 和 type 在实际开发中到底该怎么选?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

核心区别一览

interfacetype 表面上都能定义对象结构,但背后机制完全不同。先看一个对比:

// 用 interface 定义
interface Point {
  x: number;
  y: number;
}

// 用 type 定义 — 看起来一样
type Point2 = {
  x: number;
  y: number;
};

// 两者都可以这样用
const p1: Point  = { x: 1, y: 2 };
const p2: Point2 = { x: 1, y: 2 };

表面相同,但有三个关键差异:声明合并计算能力能表达的类型范围

差异 1:声明合并(Declaration Merging)

interface 可以多次声明,TypeScript 自动把它们合并成一个。type 不行,重复声明会报错。

// interface 声明合并 — 两个声明合并为一
interface Config {
  host: string;
}

interface Config {
  port: number;
}

// 现在 Config 同时有 host 和 port
const cfg: Config = { host: "localhost", port: 3000 };

// type 不支持声明合并
type Config2 = { host: string; };
// type Config2 = { port: number; };  // 错误:重复标识符

声明合并的实际用途:扩展第三方库的类型定义。

// 扩展 Express 的 Request 类型,加入自定义属性
// 这是 Express 应用中的标准做法
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
    }
  }
}

// 现在所有路由处理器都能访问 req.user 而不报错
import { Request, Response } from "express";

function authMiddleware(req: Request, res: Response, next: () => void) {
  req.user = { id: "123", role: "admin" };  // OK,不报错
  next();
}

差异 2:type 能做计算,interface 不行

type 可以表达联合、交叉、映射类型、条件类型等计算结果。interface 只能描述固定的对象结构。

// type 可以是联合类型
type ID = string | number;

// type 可以是映射类型(把所有属性变为可选)
type Partial<T> = {
  [K in keyof T]?: T[K];
};

// type 可以是条件类型
type IsString<T> = T extends string ? true : false;
type A = IsString<string>;  // true
type B = IsString<number>;  // false

// type 可以是基础类型别名
type UserId = string;
type Callback = (err: Error | null, result: string) => void;

// interface 做不到这些:
// interface ID = string | number;  // 语法错误
// interface Callback = (err: Error) => void;  // 语法错误(必须用对象语法)

差异 3:extends 的方式不同

interfaceextends 继承,报错更早、更清晰。type& 交叉合并,但合并冲突时不会立刻报错。

// interface extends — 直接继承
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const dog: Dog = { name: "Rex", breed: "Labrador" };

// type 用交叉合并
type AnimalType = { name: string };
type DogType = AnimalType & { breed: string };

const dog2: DogType = { name: "Rex", breed: "Labrador" };

冲突时的差异:

interface A1 { value: string; }
interface B1 extends A1 { value: number; }
// 错误:interface B1 不正确地扩展了 A1
// 原因:value 类型不兼容 — 报错发生在 extends 处,清晰

type A2 = { value: string };
type B2 = A2 & { value: number };
// 不报错,但 B2.value 的类型是 string & number = never
// 这会在使用 B2 时才报错,更难追踪

readonly 和可选属性 ?

interfacetype 都支持这两个修饰符:

interface UserProfile {
  readonly id: string;         // 创建后不可修改
  name: string;
  email?: string;              // 可选属性
  readonly createdAt: Date;
}

const profile: UserProfile = {
  id: "u-001",
  name: "Alice",
  createdAt: new Date(),
};

profile.name = "Bob";         // OK
profile.id = "u-002";         // 错误:无法分配到 "id",因为它是只读属性

// 只读数组
interface Config {
  readonly allowedOrigins: readonly string[];
}

const config: Config = {
  allowedOrigins: ["https://example.com"],
};

config.allowedOrigins.push("https://evil.com");  // 错误:push 不存在于 readonly string[]

索引签名

当对象的键名不确定,但键值类型固定时,使用索引签名:

// 键是字符串,值是数字
interface ScoreBoard {
  [playerName: string]: number;
}

const scores: ScoreBoard = {
  alice: 100,
  bob: 85,
};

scores.charlie = 90;   // OK
scores.dave = "high";  // 错误:string 不能赋值给 number

索引签名的限制:所有具名属性也必须兼容索引签名的值类型。

interface BadConfig {
  [key: string]: number;
  name: string;    // 错误:string 不兼容 number
  count: number;   // OK
}

// 解决方案:让索引签名包含所有可能的值类型
interface GoodConfig {
  [key: string]: number | string;
  name: string;    // OK,string 兼容 number | string
  count: number;   // OK,number 兼容 number | string
}

Record<string, T> 替代索引签名更简洁:

// 等价但更简洁
type ScoreBoard2 = Record<string, number>;

// 限定键的范围
type StatusMap = Record<"active" | "inactive" | "banned", number>;

const userCounts: StatusMap = {
  active: 150,
  inactive: 30,
  banned: 5,
};

Level 2 · 它是怎么运行的(3-5年经验)

类与接口

类可以实现接口,接口定义了类必须满足的契约:

interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Loggable {
  log(message: string): void;
}

// 一个类可以实现多个接口
class DatabaseRecord implements Serializable, Loggable {
  private data: Record<string, unknown> = {};

  serialize(): string {
    return JSON.stringify(this.data);
  }

  deserialize(json: string): void {
    this.data = JSON.parse(json);
  }

  log(message: string): void {
    console.log(`[DatabaseRecord] ${message}`);
  }
}

type 同样可以用于 implements,但 interface 语义更清晰:

type Printable = {
  print(): void;
};

class Report implements Printable {
  print(): void {
    console.log("Printing report...");
  }
}

决策树:该用哪个?

你需要的是什么?
│
├─ 联合类型?(A | B)                          → 用 type
├─ 基础类型别名?(type UserId = string)        → 用 type
├─ 映射类型?(Partial<T>, Pick<T, K>)          → 用 type
├─ 条件类型?(T extends U ? X : Y)             → 用 type
├─ 函数类型简写?(type Fn = (x: number) => void)→ 用 type
│
├─ 给类使用的契约?(implements)                → 用 interface
├─ 公共 API 的对象结构?(可能被外部 extends)   → 用 interface
├─ 需要声明合并?(扩展第三方类型)              → 用 interface
│
└─ 普通对象结构,无上述特殊需求?               → 团队统一规范即可

对比速查表

场景 interface type
定义对象结构
联合类型
交叉/合并 extends &
声明合并
映射/条件类型
implements 实现 ✅(推荐)
扩展第三方类型 ✅(推荐) 可以,但繁琐
错误信息清晰度 较高 较低(联合/交叉时)

反模式:什么都用 interface,或什么都用 type

// 反模式:用 interface 表达联合类型 — 做不到,只能绕路
// interface Status = "active" | "inactive";  // 语法错误

// 被迫用一个 interface + string literal — 丑陋且不准确
interface Status {
  value: "active" | "inactive";
}

// 正确做法:
type Status = "active" | "inactive";

// ---

// 反模式:用 type 定义供外部扩展的 API 类型
// 如果你写一个库,用户可能想扩展你的类型
type PluginOptions = {
  timeout: number;
};

// 用户无法扩展 type(无法声明合并)
// 只能用交叉,体验差

// 正确做法:公共 API 用 interface
interface PluginOptions {
  timeout: number;
}

// 用户可以在自己的代码中扩展:
interface PluginOptions {
  retryCount: number;  // 声明合并,干净利落
}

Level 3 · 规范怎么定义的(资深)

在 TypeScript 编译器内部,interfacetype 的处理方式确实不同。interface 创建一个命名的类型绑定,编译器可以按名字缓存和检索;type 创建一个别名,在每次使用时会被展开。这意味着在复杂类型场景下,interface 的错误信息通常更清晰(显示接口名),而 type 的错误信息可能展示完整的展开结构。TypeScript 团队成员 Daniel Rosenwasser 曾建议:公共 API 用 interface(方便用户 extends),内部实现按需选择。声明合并机制是 TypeScript 模块系统的重要设计决策,它允许在不修改源代码的情况下扩展第三方库的类型定义。

Level 4 · 边界与陷阱(所有人)

声明合并的意外冲突:多次声明同一个 interface 时,如果两次声明中同名属性类型不同,TypeScript 会报错。但如果是方法签名不同,它们会被合并为重载——这可能不是你期望的行为。

type& 冲突时静默产生 nevertype A = { x: string } & { x: number } 不报错,但 A.x 变成 never。用 interface extends 可以在定义时就报错。

索引签名 [key: string]: T 要求所有具名属性兼容 T:这个约束经常在混合固定字段和动态字段的对象中引起困惑。改用 Record<string, T> 更简洁。


本章要点总结

关键点 说明
声明合并 只有 interface 支持,用于扩展第三方类型
计算类型 只有 type 支持联合、映射、条件类型
extends vs & interface extends 报错更早;type & 冲突时更隐蔽
readonly 两者都支持,防止属性被意外修改
? 可选属性 两者都支持,注意与 undefined 的区别
索引签名 两者都支持,优先考虑 Record<string, T>
公共 API 优先 interface,方便用户扩展
内部实现 按需选择,团队保持一致即可

下一章介绍函数类型:重载、泛型函数、callable 对象,以及 voidundefined 的细微差别。

本章评分
4.5  / 5  (73 评分)

💬 留言讨论