什么是结构化类型?多余属性检查是怎么回事?

进阶原理对比约 7 分钟读完

一句话回答

TS 采用结构化类型:判断两个类型是否兼容只看结构,A 具备 B 要求的所有成员,A 就能赋值给 B,和类型叫什么名字、有没有声明继承关系无关,也就是"鸭子类型"。Java、C# 是名义类型,必须显式继承或实现才兼容。结构化类型允许多出属性,但把对象字面量直接赋值或传参时,TS 会额外做多余属性检查,把多出的属性当作拼写错误报出来。需要名义类型的效果时,可以用品牌类型(branded type)模拟。

详细解析

按结构判断兼容

TypeScript
interface Point {
  x: number
  y: number
}

class Vector3 {
  x = 0
  y = 0
  z = 0
}
const p: Point = new Vector3() // 可以:Vector3 有 x 和 y,多出的 z 不影响

class Cat {
  name = ''
}
class Dog {
  name = ''
}
const pet: Cat = new Dog() // 可以:结构完全相同,在 Java 中这里会编译报错
结构化类型(TS) 名义类型(Java、C#)
兼容的依据 成员的结构 类型名,以及显式声明的继承、实现关系
结构相同、名字不同 兼容 不兼容
优点 灵活,贴合 JS 中大量匿名对象的写法 能区分结构相同但含义不同的类型

TS 这样设计,是因为 JS 代码里到处是对象字面量、匿名对象和鸭子类型的写法(比如只要有 then 方法,await 就把它当 Promise 处理),结构化类型能直接描述这些已有的写法。

多余属性检查

TypeScript
interface Options {
  color?: string
  width?: number
}

function draw(options: Options) {}

// @ts-expect-error 报错:对象字面量只能指定已知属性,Options 中不存在 colour
draw({ colour: 'red', width: 100 })

const opts = { colour: 'red', width: 100 }
draw(opts) // 不报错:opts 不是直接写在这里的对象字面量

新鲜的对象字面量直接赋值给变量、作为参数或返回值时,不能有目标类型中不存在的属性。目的是捕获拼写错误:color 是可选的,按结构化规则 { colour: 'red', width: 100 } 完全合法(有 width,多出的 colour 不影响),但这几乎一定是 bug。对象字面量一旦赋给中间变量,就不再"新鲜",回到普通的结构化兼容规则。

确实需要多余属性时,可以:

  • 先赋值给中间变量,再传入
  • 用类型断言:draw({ colour: 'red' } as Options),不推荐,会把真正的拼写错误也一起放过
  • 在类型里加上索引签名 [key: string]: unknown,明确表示允许任意额外属性

函数的兼容性

参数少的函数可以赋值给参数多的函数类型,反过来不行:

TypeScript
type Handler = (event: MouseEvent, index: number) => void

const h1: Handler = (event) => console.log(event.clientX) // 可以:用不到的参数不写
// @ts-expect-error 不行:调用方只会传两个参数,第三个参数永远拿不到
const h2: Handler = (event: MouseEvent, index: number, extra: string) => {}

const nums = [1, 2, 3]
nums.forEach((n) => console.log(n)) // forEach 会传 3 个参数,回调只用第 1 个

JS 调用函数时多传的参数会被忽略,所以"少接收"是安全的;而函数需要的参数调用方不传,就会出错。数组的 forEach、map 回调能只写一个参数,靠的就是这条规则。返回值则要求能赋值给目标的返回值类型。

品牌类型:模拟名义类型

用户 ID 和订单 ID 都是 string,结构化类型分不清,传错了也不报错。可以给它们加一个只存在于类型层面的"品牌"属性:

TypeScript
type Brand<T, Name extends string> = T & { readonly __brand: Name }

type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>

// 只通过这类函数创建,可以顺便在这里做格式校验
function toUserId(raw: string): UserId {
  return raw as UserId
}

function getUser(id: UserId) {}

const userId = toUserId('u_1')
const orderId = 'o_1' as OrderId

getUser(userId) // 可以
// @ts-expect-error OrderId 不能赋值给 UserId
getUser(orderId)
// @ts-expect-error 普通字符串也不行
getUser('u_1')

__brand 属性在运行时并不存在,只是让两个类型的结构不同,没有运行时开销。金额的单位(分和元)、校验过和未校验的数据,都可以用这种方式区分。

面试官可能追问

为什么赋值给中间变量后就不检查多余属性了?

多余属性检查是为了捕获"写字面量时的拼写错误"。直接写的对象字面量只在这一处使用,多出的属性没有任何代码能访问到,基本可以断定是写错了。而一个已有的变量可能在别处被当作更完整的类型使用,多出的属性是有用的,按结构化规则只要求它具备目标需要的成员。

函数参数的类型是怎么检查的?方法为什么特殊?

开启 strictFunctionTypes(包含在 strict 中)后,函数类型的参数是逆变检查的:(e: MouseEvent) => void 不能赋值给 (e: Event) => void,因为调用方可能传入键盘事件。但用方法语法声明的成员(handle(e: Event): void)仍按双变处理,上面的赋值不会报错,这是为了兼容数组等大量内置类型的方法。想严格检查回调参数,类型里要用属性语法 handle: (e: Event) => void。

类的 private 成员会影响兼容性吗?

会。类里有 private、protected 或 # 私有字段时,只有来自同一个声明的成员才算兼容:两个类即使结构完全相同,只要各自声明了私有成员,就不能互相赋值。这让类具备了一定的名义类型特征,也是模拟名义类型的另一种方式。

易错点

  • 多余属性检查只针对直接使用的对象字面量,不代表"不允许多出属性"
  • 属性全部可选的类型(弱类型)比较特殊:赋值的对象至少要有一个同名属性,比如 const o = { colour: 'red' } 再 draw(o) 也会报错
  • {} 类型不是"空对象",而是除 null 和 undefined 以外的任何值,const x: {} = 1 是合法的;要表示"任意对象"用 object

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。