接口 vs 类型别名:一张决策树
第4章:接口 vs 类型别名:一张决策树
interface 和 type 在实际开发中到底该怎么选?这是理解本章内容的出发点。
本章核心问题:interface 和 type 在实际开发中到底该怎么选?
读完本章你将理解:
- 三个核心差异:声明合并、计算能力、extends 方式
- 一张决策树帮你做选择
- readonly、可选属性、索引签名在两者中的使用
Level 1 · 你需要知道的(1-3年经验)
核心区别一览
interface 和 type 表面上都能定义对象结构,但背后机制完全不同。先看一个对比:
// 用 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 的方式不同
interface 用 extends 继承,报错更早、更清晰。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 和可选属性 ?
interface 和 type 都支持这两个修饰符:
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 编译器内部,interface 和 type 的处理方式确实不同。interface 创建一个命名的类型绑定,编译器可以按名字缓存和检索;type 创建一个别名,在每次使用时会被展开。这意味着在复杂类型场景下,interface 的错误信息通常更清晰(显示接口名),而 type 的错误信息可能展示完整的展开结构。TypeScript 团队成员 Daniel Rosenwasser 曾建议:公共 API 用 interface(方便用户 extends),内部实现按需选择。声明合并机制是 TypeScript 模块系统的重要设计决策,它允许在不修改源代码的情况下扩展第三方库的类型定义。
Level 4 · 边界与陷阱(所有人)
声明合并的意外冲突:多次声明同一个 interface 时,如果两次声明中同名属性类型不同,TypeScript 会报错。但如果是方法签名不同,它们会被合并为重载——这可能不是你期望的行为。
type 的 & 冲突时静默产生 never:type 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 对象,以及 void 和 undefined 的细微差别。