《TypeScript编程入门》5.3 结构化类型与两者取舍

本节回答两个贯穿全章的问题:为什么 TypeScript 只比较类型结构、不比较类型名字,以及 interface 与 type 到底该怎么选。内容涵盖结构化类型(鸭子类型)的判定规则、它带来的「意外兼容」风险、用品牌类型模拟名义类型的工程手法,并给出一份可直接照做的取舍清单与代码评审要点。读完本节,你既能解释类型兼容背后的原理,也能在团队里统一 interface 与 type 的使用规范。

本节目标:理解 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 本就是鸭子类型)

二、兼容判定的具体规则

结构化类型不是「随便都能赋值」,它有一套精确规则。对象类型上,核心是两条:

  1. 目标类型的每个必需成员,源类型都必须具备;
  2. 对应成员的类型必须兼容(对象类型继续递归比较,函数类型按参数逆变、返回值协变)。
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 统一风格,一致性优先

小结

本节回答了两个贯穿全章的问题:

  1. 为什么只比较结构? 因为 TypeScript 选择了结构化类型系统。它换来了与 JavaScript 生态的无缝契合、跨模块协作的松耦合,代价是类型失去「身份」、语义不同的同构类型会互相兼容。
  2. interface 还是 type? 描述对象契约与需要声明合并时用 interface,表达联合、交叉、元组、函数与品牌类型时用 type。团队内保持一致,比争论哪个「更对」更有价值。

到这里,第 5 章「接口与类型别名」就结束了。我们已经能够为数据建模、表达多种可能、在必要时强制名义类型。但目前的类型都还只是「描述数据的形状」——当数据需要和行为绑在一起,需要封装状态、控制可见性、支持继承与多态时,就该轮到类出场了。下一章从 类、访问修饰符与参数属性 开始,看 TypeScript 如何在 JavaScript 的类之上,补齐一套完整的面向对象表达力。

阅读导航:上一节:5.2 type 别名、联合与交叉 · 下一节:6.1 类、访问修饰符与参数属性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes