本节目标:把「类型 A 能否赋给类型 B」从直觉变成可复述的规则。读完你能说清结构化类型系统的判定顺序、解释函数参数为什么逆变、读懂
Type 'X' is not assignable to type 'Y'这一族报错,并知道结构化兼容会在哪些场景放过真实的 bug。
1.1 结构化类型与兼容性判定
入门篇里我们已经用过 interface 与 type,也知道「名字不同、结构一样就能互相赋值」。但那只是结论。本节要回答的是编译器按什么顺序做这件事:只有知道判定规则,你才能在报错时快速定位是哪个成员、哪个方向出了问题,也才能理解后面几章——泛型推导、条件类型、发布期的类型破坏性变更——为什么会那样表现。
一、从名义到结构:判定依据的根本差异
在 Java、C# 这类语言里,两个类型是否兼容取决于名字与继承声明。即使 UserId 和 OrderId 内部一模一样,只要类名不同,编译器就拒绝互相赋值。这叫名义类型系统(nominal typing)。
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:OrderId 拥有 UserId 要求的全部成员
这不是「TypeScript 比较宽松」,而是它与 JavaScript 生态对齐的必然选择:JS 本身是鸭子类型,运行时不携带类型信息,若编译期再引入名义约束,就会与已有的动态库格格不入。
| 维度 | 名义类型(Java/C#) | 结构化类型(TypeScript) |
|---|---|---|
| 兼容依据 | 类型名与继承关系 | 成员结构 |
| 跨模块协作 | 需共享同一份类型声明 | 各自声明即可兼容 |
| 重命名影响面 | 大(名字即身份) | 无(结构不变即不受影响) |
| 误判风险 | 低 | 高(结构相同即视为同类) |
| 与 JS 生态契合 | 差 | 好 |
二、可赋值性:阅读赋值报错的四个观察点
当你要把一个值赋给带类型的变量,编译器并不是「看一眼像不像」,可以从下面四个角度分析(这是阅读模型,不是编译器内部的固定调用顺序):
- 同一性检查:源与目标是否解析为同一个类型。
- 成员存在性:目标类型的每个必需成员,源类型是否都具备。
- 成员类型兼容:对应成员继续递归比较;函数成员按参数逆变、返回值协变比较。
- 多余属性检查:仅对直接书写的对象字面量额外生效,防止拼写错误。
前三条是「结构判定」,第四条是「防手滑」,务必分开理解:
interface Point { x: number; y: number }
const a: Point = { x: 1, y: 2 }; // OK
const b: Point = { x: 1, y: 2, z: 3 }; // 报错:Object literal may only specify known properties
const raw = { x: 1, y: 2, z: 3 };
const c: Point = raw; // OK:raw 不是字面量,多余属性检查不生效
结论是:成员更多的一方可以赋给成员更少的一方,反之不行。这也说明 Aged 在结构上是 Named 的子类型——子类型关系由结构决定,而不是由 extends 决定。
多余属性检查有几处「失效边界」,知道它们能省下大量调试时间:
interface Config { host: string }
const spread = { host: 'localhost', prot: 8080 };
const c1: Config = spread; // 不报错(经由变量)
const c2: Config = { ...spread }; // 不报错(展开表达式)
const c3: Config = spread as Config; // 不报错(断言)
function load(c: Config): void {}
load({ host: 'h', prot: 1 }); // 报错(直接实参,检查生效)
也就是说,只有「直接书写、且未经中间变量/展开/断言」的对象字面量才会被查。它防的是手滑拼错,不是结构错误——不要指望它兜住所有多余字段。
三、函数成员的方向:参数逆变、返回值协变
对象成员里最容易记反的是函数。先看返回值:
type Maker = () => { name: string };
type WideMaker = () => { name: string; age: number };
const wide: WideMaker = () => ({ name: 'Ada', age: 36 });
const m: Maker = wide; // OK:返回值「提供更多」是安全的
调用方只承诺使用 name,实现方多给一个 age 不会破坏任何调用点,所以返回值方向可以更具体(协变)。
参数方向相反:
type Handler = (e: { type: string }) => void;
type WideHandler = (e: { type: string; payload: unknown }) => void;
declare const wideHandler: WideHandler;
const h: Handler = wideHandler; // 报错(strictFunctionTypes 下)
declare const narrowHandler: Handler;
const h2: WideHandler = narrowHandler; // OK
直觉是:调用 h({ type: 'click' }) 时,h 只保证传入 { type },而 wideHandler 却声称自己要读 payload。让「要求更多」的实现顶替「要求更少」的契约,会留下运行时的 undefined。所以参数位置必须逆变——要求更少才能顶替要求更多。
strictFunctionTypes 只对函数类型的属性与参数启用严格逆变,对方法简写语法(method(e) {})仍保持双变,这是为了兼容既有库:
interface A { m(e: { type: string }): void }
interface B { m(e: { type: string; payload: unknown }): void }
declare const b: B;
const a: A = b; // OK:方法语法按双变比较,不报错
type FA = { m: (e: { type: string }) => void };
type FB = { m: (e: { type: string; payload: unknown }) => void };
declare const fb: FB;
const fa: FA = fb; // 报错:属性语法按逆变比较
同一个形状,只因为一个用方法简写、一个用函数属性,检查结果就不同——这是 strictFunctionTypes 最常见的「为什么这里报错、那里不报」的来源。
四、数组协变:一段历史包袱
数组在 TypeScript 里是协变的:
const nums: number[] = [1, 2, 3];
const anys: (number | string)[] = nums; // OK
anys.push('oops'); // 编译通过
console.log(nums[3].toFixed(2)); // 运行时崩溃:nums[3] 是字符串
这里其实漏掉了一个真实 bug。理论上数组应当不变(invariant),但若改成不变,Array<Dog> 就无法赋给 Array<Animal>,大量既有代码会报错。TypeScript 团队选择了「可用性优先」,代价是数组写入需要靠 readonly T[] 与 as const 自觉约束。对外暴露的只读数据请用 readonly 数组,把写入能力收敛在内部。
五、any、unknown、never、void 的特殊地位
兼容性规则之外,有四个类型被编译器特殊对待,它们的表现常常让「结构判定」失效:
let a: any = 1;
a = 'x'; // OK
const s: string = a; // OK:any 可赋给除 never 之外的类型,也可接受任何类型
let u: unknown = 1;
u = 'x'; // OK
const s2: string = u; // 报错:Type 'unknown' is not assignable to type 'string'
declare function fail(): never;
const n1: number = fail(); // OK:never 是所有类型的子类型
const n2: never = 1; // 报错:Type 'number' is not assignable to type 'never'
| 类型 | 可赋给谁 | 谁可赋给它 | 用途 |
|---|---|---|---|
any | 除 never 外的类型 | 任何类型 | 逃生舱,会关闭检查 |
unknown | 只有 any / unknown | 任何类型 | 安全的「未知」 |
never | 任何类型 | 只有 never | 不可达、穷尽检查 |
void | any / unknown / void | undefined / void / never / any | 表示忽略返回值 |
上表讨论 strictNullChecks 下的值赋值。any 绕过多数兼容性检查,但不能赋给 never,因此不能严格称为所有类型的子类型;它仍会让许多检查沿着数据流失效。这也是为什么公共 API 里应优先用 unknown——unknown 只能被收窄,不能被随意使用。
void 值得单独说一句:在函数返回值位置,它有一条特例规则——返回 void 的函数类型可以接受任何返回值的实现:
type Notify = () => void;
const notify: Notify = () => 42; // OK:返回值被忽略
const arr: number[] = [];
[1, 2, 3].forEach((x) => arr.push(x)); // 回调返回 number,forEach 期望 void,仍然 OK
这条规则让「回调可以顺手返回点东西」变得自然,代价是返回 void 的回调里写错返回值不会被发现——() => arr.push(x) 返回的是新长度,而作者本意可能完全不是它。
六、private/protected:结构化系统里的名义角落
结构化系统有一个例外:目标类型包含 private 或 protected 成员时,源类型对应成员必须来自同一份声明。
class UserId {
private readonly tag = 'UserId';
constructor(public value: string) {}
}
class OrderId {
private readonly tag = 'OrderId';
constructor(public value: string) {}
}
declare const oid: OrderId;
const uid: UserId = oid;
// 报错:Types have separate declarations of a private property 'tag'.
两个类结构上几乎一致,但 tag 是各自声明的私有成员,于是编译器认定它们互不兼容。这条规则正是品牌类型的语法基础:私有成员等于类型身份。
七、当结构化兼容放过 bug:品牌类型
结构化系统最大的代价是类型失去身份。下面这段代码能编译,但显然是错的:
type Celsius = number;
type Fahrenheit = number;
function toFahrenheit(c: Celsius): Fahrenheit {
return c * 1.8 + 32;
}
const t: Celsius = 30;
toFahrenheit(toFahrenheit(t)); // 编译通过,语义上荒唐
Celsius 与 Fahrenheit 都只是 number 的别名,编译器无法区分。工程解法是品牌类型(branded 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;
}
declare function fetchOrder(id: OrderId): void;
fetchOrder(asUserId('u-100'));
// 报错:Type 'UserId' is not assignable to type 'OrderId'.
[brand] 属性在运行时并不存在,所以零运行时开销——它纯粹是给编译器看的。落地时的关键是把「铸造」收敛到少数几个工厂函数(asUserId、parseEmail),让 as 断言只出现在这些经过评审的位置,而不是散落各处。
要理解这类结构判定在真实编译器里如何与约束求解、类型推断交织,可以延伸阅读 编译器的类型推断与检查 与 Scala 类型系统 。
八、索引签名、可选成员与重载的判定细节
三种结构在兼容判定里有额外规则,不留意就会写出「编译通过、运行崩」的代码。
索引签名:有索引签名的类型可以接受任意键,但值的类型必须统一:
interface Dict { [key: string]: number }
const d: Dict = { a: 1, b: 2 }; // OK
const bad: Dict = { a: 1, b: 'x' }; // 报错:Type 'string' is not assignable to type 'number'
反过来,interface 通常不能隐式获得字符串索引签名,即使其已声明属性都符合;对象字面量类型与类型别名在满足条件时可能隐式兼容:
interface User { id: number; name: string }
const u: User = { id: 1, name: 'Ada' };
const dict: Dict = u;
// 报错:Index signature for type 'string' is missing in type 'User'.
原因是编译器无法保证 User 不会在别处被声明合并新增一个 string 类型的属性。
可选成员:{ a?: number } 与 { a: number | undefined } 在默认配置下互相兼容,因为可选成员被视为「可能存在,值可能是 undefined」。打开 exactOptionalPropertyTypes 后两者分离,{ a: undefined } 不再能赋给 { a?: number }。这是少数「严格化开关会直接改变兼容结果」的配置项。
重载:兼容性检查看的是实现签名是否与所有重载签名兼容,而调用方看到的是重载列表:
function parse(v: string): number;
function parse(v: number): string;
function parse(v: string | number): string | number {
return typeof v === 'string' ? Number(v) : String(v);
}
const p: (v: string) => number = parse; // OK:匹配第一个重载
const q: (v: boolean) => void = parse; // 报错:没有匹配的重载
重载与条件类型常常功能重叠,选择标准是「调用点是否真的需要多个签名」——只需要一个签名时,用联合类型加条件返回类型更简洁。
九、常见报错速查
| 报错信息 | 真实含义 | 修法 |
|---|---|---|
Property 'x' is missing in type 'A' but required in type 'B' | 目标必需成员在源类型中缺失 | 补成员,或放宽目标类型 |
Types have separate declarations of a private property 'x' | 触发了名义比较 | 统一到同一份声明,或改用品牌类型 |
Type 'string' is not assignable to type 'number' | 某成员类型不兼容 | 顺着箭头看具体成员 |
Object literal may only specify known properties | 多余属性检查 | 去掉错拼字段,或先赋给中间变量 |
Argument of type 'A' is not assignable to parameter of type 'B' | 参数位置方向写反 | 参数写宽、返回值写窄 |
Index signature for type 'string' is missing in type 'A' | 源类型没有索引签名 | 显式加索引签名,或改用 Record |
Type 'unknown' is not assignable to type 'string' | unknown 未经收窄 | 先用守卫或断言收窄 |
十、本节要点速查
| 主题 | 结论 |
|---|---|
| 判定依据 | 只看结构,不看名字 |
| 对象赋值 | 成员多者可赋给成员少者 |
| 函数返回值 | 协变(可提供更多) |
| 函数参数 | 逆变(要求更少才可顶替);方法语法仍双变 |
| 数组 | 协变,有历史包袱;对外暴露用 readonly |
| 私有成员 | 使比较退化为名义类型 |
| 防误兼容 | 品牌类型 T & { [brand]: 'X' } |
| 特殊类型 | any 双向通行,unknown 只能被收窄,never 是底类型 |
小结
本节把「能否赋值」拆成了可复述的规则:
- 判定依据是结构。目标类型的每个必需成员,源类型都必须具备且类型兼容;名字不参与判定。
- 函数方向要记牢。返回值协变、参数逆变;数组协变是可用性优先的历史妥协。
- 身份可以补回来。
private/protected成员让比较退化为名义,品牌类型则用零运行时开销的标记把身份编码进类型。
但这里有一个前提一直被默认接受:这些规则只存在于编译期。那么编译完成后,类型究竟还剩多少?interface、泛型、类型断言在运行时是「不存在」还是「以别的形式存在」?下一节 类型擦除与运行时边界
就来回答这个问题——它是理解本书后半程所有运行时话题(装饰器元数据、序列化、不可信输入校验)的地基。对「公共类型如何随版本漂移」感兴趣的读者,也可以先看一眼 semver、发布与类型破坏性变更
。
阅读导航:下一节:1.2 类型擦除与运行时边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。