《TypeScript高级编程》1.1 结构化类型与兼容性判定

本节把「类型 A 能否赋给类型 B」从直觉变成可复述的规则:先对照名义类型理解结构化类型为何只看成员结构,再拆解可赋值性的判定顺序,涵盖成员存在性、递归兼容、参数逆变与返回值协变,并解释数组协变的历史包袱、any 与 unknown 的特殊行为、private 成员如何让比较退化为名义类型,以及结构化兼容放过 bug 时的品牌类型兜底做法。读完你能独立解读 not assignable 报错。

本节目标:把「类型 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 生态契合差好

二、可赋值性:阅读赋值报错的四个观察点

当你要把一个值赋给带类型的变量,编译器并不是「看一眼像不像」,可以从下面四个角度分析(这是阅读模型,不是编译器内部的固定调用顺序):

  1. 同一性检查:源与目标是否解析为同一个类型。
  2. 成员存在性:目标类型的每个必需成员,源类型是否都具备。
  3. 成员类型兼容:对应成员继续递归比较;函数成员按参数逆变、返回值协变比较。
  4. 多余属性检查:仅对直接书写的对象字面量额外生效,防止拼写错误。

前三条是「结构判定」,第四条是「防手滑」,务必分开理解:

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不可达、穷尽检查
voidany / unknown / voidundefined / 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 是底类型

小结

本节把「能否赋值」拆成了可复述的规则:

  1. 判定依据是结构。目标类型的每个必需成员,源类型都必须具备且类型兼容;名字不参与判定。
  2. 函数方向要记牢。返回值协变、参数逆变;数组协变是可用性优先的历史妥协。
  3. 身份可以补回来。private/protected 成员让比较退化为名义,品牌类型则用零运行时开销的标记把身份编码进类型。

但这里有一个前提一直被默认接受:这些规则只存在于编译期。那么编译完成后,类型究竟还剩多少?interface、泛型、类型断言在运行时是「不存在」还是「以别的形式存在」?下一节 类型擦除与运行时边界 就来回答这个问题——它是理解本书后半程所有运行时话题(装饰器元数据、序列化、不可信输入校验)的地基。对「公共类型如何随版本漂移」感兴趣的读者,也可以先看一眼 semver、发布与类型破坏性变更 。

阅读导航:下一节:1.2 类型擦除与运行时边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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