2.1 结构化类型与类型兼容性


2.1 结构化类型与类型兼容性

在知识地图里这是第二章的第一块基石。TS 判断"类型 A 能否赋给类型 B",看的不是名字,而是形状——这叫结构化类型(structural typing)。理解它,你才明白为什么 TS 不像 Java 那样讲究"是不是同一个类"。

名字不重要,形状才重要

// 两个完全无关的类,但形状一致 class Cat { name: string; constructor(n: string) { this.name = n; } } class Dog { name: string; constructor(n: string) { this.name = n; } } function printName(a: { name: string }) { console.log(a.name); } printName(new Cat("咪")); // OK printName(new Dog("汪")); // OK,形状匹配即可

输入输出:两个类都能传给 printName,尽管它们没有继承关系。契约判定的是"有没有 name: string 这个能力",而非"是不是某个特定类"。这和名义类型语言(Java/C#)正相反——那边必须显式声明实现接口才认。

子类型兼容性:多出来的属性不碍事

结构化类型下,目标类型需要的字段,源类型都有,就兼容。源类型多出字段通常也接受(除非是对象字面量被 excess property check 拦,见下)。

interface Animal { name: string; } interface Dog extends Animal { breed: string; } const a: Animal = { name: "动物" }; const d: Dog = { name: "狗", breed: "柴犬" }; a = d; // OK:Dog 拥有 Animal 的全部字段,可赋给 Animal // d = a; // 报错:Animal 缺 breed

背景:函数参数常声明成宽泛的 Animal,调用时传具体的 Dog,这是多态的常见写法。操作:把更具体的 d 赋给更宽的 a。结果:成功。解读:兼容性方向是"具体可赋给宽泛",反方向不行。变式:数组、对象嵌套同理,逐字段递归比较。

对象字面量的 excess property check

有个坑新手必踩:变量赋值宽容,但对象字面量直接传参会额外检查"不能有多余属性"。

interface Point { x: number; y: number; } const p: Point = { x: 1, y: 2, z: 3 }; // 报错:对象字面量不存在属性 z // 但若先存到变量再赋值,多余属性被忽略 const tmp = { x: 1, y: 2, z: 3 }; const p2: Point = tmp; // OK,z 被静默忽略

这不是矛盾,而是设计权衡:字面量是你"当场写的",多写字段大概率是笔误,所以拦;变量是"已经存在的对象",多字段可能是别处需要的,所以放。理解动机就不会困惑。

下图标出结构化判定的递归过程:

02-01-fig01-2

函数类型的兼容:看参数和返回

结构化类型对函数同样适用,但规则有反转(详见 2.4)。先看基础:返回类型要协变,参数类型要能"接住"调用方给的值。

type F1 = () => Animal; type F2 = () => Dog; let fn: F1 = () => new Dog("狗"); // OK:返回 Dog 可赋给期望的 Animal

兼容性判定的边界:递归与循环

类型引用自己时(如链表节点),结构化比较会做循环检测,不会栈溢出。

interface Node { value: number; next: Node | null; } const n: Node = { value: 1, next: null }; // 自引用结构完全合法

函数参数位置的逆变性(实战表现)

结构化类型对函数参数的比较方向和返回值相反:能接受"更宽参数"的函数,可以赋给期望"更窄参数"的位置。这是为了让调用方永远能安全地传值。

type Handler = (e: { type: string }) => void; // 一个能处理更宽类型的函数 const logAny: Handler = (e) => { console.log(e.type); }; // 调用方只会传 { type: string } 及其子类型,logAny 完全接得住 function dispatch(h: Handler) { h({ type: "click" }); } dispatch(logAny); // OK // 反例:参数写窄会失败 type Strict = (e: { type: string; id: number }) => void; const logStrict: Strict = (e) => { console.log(e.id); }; // dispatch(logStrict); // 报错:dispatch 可能传 { type: string } 缺 id

背景:事件处理、回调注册常踩这个方向问题。操作:回调参数声明成"调用方承诺会提供的最小集"。结果:函数更易复用。解读:参数逆变、返回协变,是结构化类型在函数上的具体表现,2.4 会完整展开。

结构化 vs 名义:何时会"形状一样却不想要"

结构化类型最大的副作用,是两种语义不同但形状巧合一致的类型可互相赋值。除了上面的品牌类型,还可用多余字段强制区分:

interface Meter { value: number; unit: "m"; } interface Second { value: number; unit: "s"; } const m: Meter = { value: 3, unit: "m" }; const s: Second = { value: 3, unit: "s" }; // m = s; // 报错:unit 字面量不同,形状不匹配 // 借助字面量字段,零成本地把单位钉死

这种"字面量哨兵"比品牌类型更直白,适合单位、状态枚举等场景。代价是对象必须显式带这个字段,不能只是裸 number

工程取舍:名义化需求怎么办

有些场景你确实想"只有同类才能赋值",比如货币单位(美元和日元都是 number,但绝不能混)。TS 没有原生名义类型,可用"品牌类型"模拟:

type USD = number & { __brand: "USD" }; type JPY = number & { __brand: "JPY" }; function usd(n: number): USD { return n as USD; } const a: USD = usd(10); const b: JPY = a; // 报错:品牌不同,即使底层都是 number

这在金融、单位换算里非常实用——用极小的成本把"形状相同但语义不同"的坑堵上。

本节要点回顾

  • 结构化类型:兼容性看形状不看名字
  • 具体类型可赋给宽泛类型,反之不行
  • 对象字面量多字段被 excess property check 拦,变量赋值则忽略
  • 函数兼容规则见 2.4,品牌类型可模拟名义语义

⚠️ 别把"形状匹配即可赋值"理解成"随便传"。字面量多余属性检查只覆盖直接字面量,从变量中转一道就绕过了,关键数据仍要靠运行时校验兜底。

💡 需要区分语义相同但单位不同的数值时,品牌类型是零运行时成本的好办法,比加一堆注释管用。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U