本节目标:理解 TypeScript 的「结构化类型系统」为什么只比较结构、不比较名字;掌握类型兼容的判定规则与它带来的意外兼容风险;学会用品牌类型在需要时模拟名义类型;最后得到一份
interface与type的可执行取舍清单。
5.3 结构化类型与两者取舍
前两节我们分别掌握了 interface 与 type 的语法。但有两件事一直悬着没有回答:第一,为什么两个名字完全不同的类型,只要结构一样就能互相赋值?第二,既然能力重叠,interface 和 type 到底按什么标准选?本节把这两个问题一次讲透。
一、结构化类型 vs 名义类型
先做个对比。在 Java 或 C# 这类语言里,类型是否兼容取决于名字:即使 UserId 和 OrderId 的内部结构一模一样,只要类名不同,handle(oid) 就会被编译器拒绝。这叫名义类型系统(nominal typing)。
有趣的是,TypeScript 里也存在一个「名义比较」的角落——只要类含有 private 或 protected 成员,比较方式就会退化为名义类型:
class UserId {
private readonly tag = 'UserId';
constructor(public value: string) {}
}
class OrderId {
private readonly tag = 'OrderId';
constructor(public value: string) {}
}
function handle(id: UserId): void {
console.log(id.value);
}
handle(new OrderId('o-1'));
// 报错:Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
// Types have separate declarations of a private property 'tag'.
两个类在结构上几乎一致,唯独 tag 是各自声明的私有成员,于是编译器判定它们互不兼容。这个机制正好是下一节「品牌类型」的语法基础——私有成员 = 类型身份。
TypeScript 走的是另一条路——结构化类型系统(structural typing):
interface UserId { value: string }
interface OrderId { value: string }
function handle(id: UserId): void {
console.log(id.value);
}
const oid: OrderId = { value: 'o-1' };
handle(oid); // 完全 OK,没有任何报错
在 TypeScript 看来,「OrderId 拥有 value: string」这一事实,已经足以让它充当 UserId。类型是否兼容,只看结构是否满足,与类型名无关。这就是常说的「鸭子类型」在类型系统层面的静态实现:如果它走起来像鸭子、叫起来像鸭子,那它就可以当鸭子用。
| 维度 | 名义类型(Java/C#) | 结构化类型(TypeScript) |
|---|---|---|
| 兼容判定依据 | 类型名 / 继承关系 | 成员结构 |
| 跨模块协作 | 需要共享类型定义 | 各自定义即可兼容 |
| 对重构的友好度 | 改名字影响面大 | 只要结构不变就不受影响 |
| 误判风险 | 低(名字即身份) | 高(结构相同即视为同类) |
| 与 JS 生态契合度 | 低 | 高(JS 本就是鸭子类型) |
二、兼容判定的具体规则
结构化类型不是「随便都能赋值」,它有一套精确规则。对象类型上,核心是两条:
- 目标类型的每个必需成员,源类型都必须具备;
- 对应成员的类型必须兼容(对象类型继续递归比较,函数类型按参数逆变、返回值协变)。
interface Named { name: string }
interface Aged { name: string; age: number }
let named: Named;
let aged: Aged = { name: 'Ada', age: 36 };
named = aged; // OK:Aged 拥有 Named 要求的全部成员(还多了一个 age)
aged = named; // 报错:Property 'age' is missing in type 'Named'
关键结论是:成员更多的一方可以赋值给成员更少的一方,反之不行。这被称为「子类型关系由结构决定」——Aged 结构上比 Named 更具体,所以 Aged 是 Named 的子类型。
这也解释了我们 5.1 节见过的多余属性检查:const p: Point = { x: 1, y: 2, z: 3 } 之所以报错,不是因为 { x, y, z } 不满足 Point(它满足),而是 TypeScript 对「直接赋值的对象字面量」额外做了一层拼写错误防护。
函数类型的方向很容易记反,这里单独列出:
type Handler = (e: { type: string }) => void;
// 参数可以「要求更少」,返回值可以「提供更多」
const h: Handler = (e: { type: string; payload: unknown }) => {
console.log(e.type);
};
函数参数是逆变的:目标函数要求的参数是 { type },实现方却声明自己需要 { type, payload }——这看似矛盾,但 TypeScript 默认允许(strictFunctionTypes 下对函数属性会严格检查)。初学阶段记住实践结论即可:回调参数写宽一点通常更安全。
三、结构化类型的代价:意外兼容
结构化类型最大的好处是灵活,最大的代价是类型失去了「身份」。下面这段代码能通过编译,但显然是 bug:
type Celsius = number;
type Fahrenheit = number;
function toFahrenheit(c: Celsius): Fahrenheit {
return c * 1.8 + 32;
}
const temp: Celsius = 30;
const wrong = toFahrenheit(temp); // 86
console.log(toFahrenheit(wrong)); // 186.8:误把华氏温度当摄氏,编译器不报错
在名义类型系统里,Celsius 和 Fahrenheit 是两种不同的类型,混用会直接报错。但在 TypeScript 里,它们都只是 number 的别名,编译器无法区分。
同样的坑在「ID 混用」场景更常见:
type UserId = string;
type OrderId = string;
async function fetchOrder(id: OrderId): Promise<unknown> { return { id }; }
const uid: UserId = 'u-100';
fetchOrder(uid); // 编译通过,但语义上完全错了
这类问题在编译期无法被结构化类型捕获,只能靠约定或模拟名义类型来防。想了解类型系统在编译器中如何做这类兼容判定,可以延伸阅读 编译器的类型推断与检查 。
四、用品牌类型模拟名义类型
工程上解决「结构化误兼容」的通用手法叫品牌类型(branded type),也叫不透明类型(opaque type)。思路是:给类型附加一个只存在于类型层面、运行时不存在的标记。
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// 创建时必须显式「铸造」,通常通过校验函数完成
function asUserId(raw: string): UserId {
if (!/^u-\d+$/.test(raw)) {
throw new Error(`非法的 UserId: ${raw}`);
}
return raw as UserId;
}
function fetchOrder(id: OrderId): void { /* ... */ }
const uid = asUserId('u-100');
fetchOrder(uid); // 报错:Type 'UserId' is not assignable to type 'OrderId'.
fetchOrder(uid as never); // 只有显式绕过类型系统才能编译
原理是:UserId 的结构是 string & { [brand]: 'UserId' },而 OrderId 是 string & { [brand]: 'OrderId' }。两者结构不再相同,结构化类型自然拒绝互相赋值。而 [brand] 这个属性在运行时并不存在,所以零运行时开销——它纯粹是给编译器看的标记。
品牌类型的另一个常见变体是「校验后标记」,用于表达「这个字符串已经通过校验」:
type Email = Brand<string, 'Email'>;
function parseEmail(input: string): Email {
if (!input.includes('@')) throw new Error('邮箱格式错误');
return input as Email;
}
function sendMail(to: Email): void { /* ... */ }
sendMail('not-an-email'); // 报错:Type 'string' is not assignable to 'Email'
sendMail(parseEmail('a@b.com')); // OK
这样就把「必须经过校验才能使用」这一约定,从口头规范变成了编译器强制。它与 13.2 节要讲的 Zod 校验属于同一思路的两种实现:Zod 负责运行时验证并推导类型,品牌类型负责在类型层面阻止绕过。
五、interface 与 type 的取舍清单
现在回到第二个问题。综合前三节,可以给出一份可直接执行的取舍规则。
优先选 interface 的场景:
- 描述对象结构,尤其是公共 API、SDK 的对外契约(错误信息更可读,编辑器跳转体验更好)。
- 需要声明合并:给第三方库或全局对象打补丁(见 12.3 节)。
- 需要用类去
implements,希望类型与实现形成明确的契约关系(见 6.3 节)。 - 需要
extends多个接口做组合。
必须用 type 的场景:
- 联合类型:
type Status = 'idle' | 'loading' | 'done'。 - 交叉类型:
type Person = HasName & HasAge。 - 元组:
type Pair = [number, number]。 - 函数类型别名:
type Handler = (e: Event) => void。 - 条件类型、映射类型、模板字面量类型(9~10 章的内容)。
- 品牌类型:
type UserId = Brand<string, 'UserId'>。
团队层面的一条实用建议: 与其在代码评审里逐条争论,不如选一条默认规则并写进 ESLint 配置。社区最常见的选择是 @typescript-eslint/consistent-type-definitions 规则,可以统一强制为 interface 或 type 其中之一。无论选哪个,一致性比「正确性」更重要——同一份代码库里两种风格随机混用,才是真正的维护负担。
六、常见坑一览
坑一:把「结构相同」当成「语义相同」。
type Meters = number;
type Seconds = number;
const distance: Meters = 100;
const duration: Seconds = 9.58;
const speed = distance / duration; // 10.43,但类型是 number,丢掉了单位语义
涉及物理量、货币、ID 等领域时,建议直接用品牌类型。
坑二:误以为 interface 赋值后还会持续检查。
interface Config { host: string; port: number }
const cfg: Config = { host: 'localhost', port: 8080 };
const loose = cfg; // loose 的类型是 Config
const extra = { ...cfg, timeout: 3000 }; // extra 是推断出的新结构类型
extra 的类型是推导结果,不是 Config。它比 Config 多一个字段,因此可以赋给 Config;但如果你希望它严格保持 Config,需要显式标注 const extra: Config = ...(此时多余字段会触发检查)。
坑三:用 any 绕过结构化检查。
const uid = 'u-100' as any;
fetchOrder(uid); // 编译通过——品牌类型的防线被 any 击穿
品牌类型的保护是编译期的,any 和 as 断言可以绕过。这也是 3.3 节强调「少用 any」的另一个理由。团队里应配合 ESLint 的 no-explicit-any 规则限制滥用。
坑四:期待编译期能保证运行时数据合规。
结构化类型描述的是「我们希望数据长什么样」。当后端返回的数据并不符合声明时,TypeScript 完全帮不上忙——这属于 13.1 节「类型擦除带来的运行时盲区」。边界数据必须用运行时校验兜底,可参考 TypeScript 运行时验证与类型安全 。
七、工程实践:什么时候该强制名义类型
不是所有 ID 都值得做成品牌类型,过度使用反而会让代码充满 as 断言。以下是几条实践经验:
| 场景 | 是否建议品牌类型 | 理由 |
|---|---|---|
| 金额、货币单位 | 建议 | 币种混用是真实事故来源 |
| 领域实体 ID(用户/订单) | 建议 | 参数顺序写反时无法被结构检查发现 |
| 经过校验的输入(Email、URL) | 建议 | 把「已校验」状态编码进类型 |
普通的 string 配置项 | 不建议 | 收益低,徒增类型噪音 |
| 短生命周期的局部变量 | 不建议 | 作用域小,误用概率低 |
配套的落地方式是把「铸造」收敛到少数几个工厂函数里(如 asUserId、parseEmail),让 as 断言只出现在这些经过评审的位置。这样既拿到了名义类型的安全性,又不会让断言散落各处。
对更大的工程组织方式感兴趣的话,可以延伸阅读 Node.js 中的 TypeScript 工程实践 与 TypeScript 类型优先的开发方式 。
八、本章要点速查
| 主题 | 结论 |
|---|---|
| 兼容判定 | 只看结构,不看名字;成员多者可赋给成员少者 |
| 函数参数 | 默认宽松(逆变方向),strictFunctionTypes 下更严 |
| 结构化代价 | 语义不同的同构类型无法区分 |
| 解法 | 品牌类型:T & { [brand]: 'X' },零运行时开销 |
| 选 interface | 对象契约、声明合并、implements、extends 组合 |
| 选 type | 联合、交叉、元组、函数别名、条件类型、品牌类型 |
| 团队规范 | 用 ESLint 统一风格,一致性优先 |
小结
本节回答了两个贯穿全章的问题:
- 为什么只比较结构? 因为 TypeScript 选择了结构化类型系统。它换来了与 JavaScript 生态的无缝契合、跨模块协作的松耦合,代价是类型失去「身份」、语义不同的同构类型会互相兼容。
interface还是type? 描述对象契约与需要声明合并时用interface,表达联合、交叉、元组、函数与品牌类型时用type。团队内保持一致,比争论哪个「更对」更有价值。
到这里,第 5 章「接口与类型别名」就结束了。我们已经能够为数据建模、表达多种可能、在必要时强制名义类型。但目前的类型都还只是「描述数据的形状」——当数据需要和行为绑在一起,需要封装状态、控制可见性、支持继承与多态时,就该轮到类出场了。下一章从 类、访问修饰符与参数属性 开始,看 TypeScript 如何在 JavaScript 的类之上,补齐一套完整的面向对象表达力。
阅读导航:上一节:5.2 type 别名、联合与交叉 · 下一节:6.1 类、访问修饰符与参数属性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。