TypeScript组合扩展类型时属性依赖必选校验失效问题
以下是一个单独使用时逻辑正常,但组合为更大类型时会出现异常的TypeScript类型场景:
核心约束规则:
- 若定义了
scrollToItem,则必须同时定义getRowId - 若未定义
scrollToItem,则getRowId为可选属性
最初的类型定义可以正常工作:
type ScrollProps<T> = | { scrollToItem: T; getRowId: (row: T) => string; } | { getRowId?: (row: T) => string; }; const a: ScrollProps<number> = {}; // ok const b: ScrollProps<number> = { scrollToItem: 6, getRowId: (row) => '' }; // ok const c: ScrollProps<number> = { getRowId: (row) => '' }; // ok const d: ScrollProps<number> = { scrollToItem: 6 }; // error - 符合预期
后续扩展为从导入的库类型中选取getRowId属性时,逻辑依然正常:
type LibraryObject<T> = { getRowId?: (row: T) => string; otherProps?: boolean; }; type ScrollProps<T> = | ({ scrollToItem: T } & Required<Pick<LibraryObject<T>, 'getRowId'>>) | Pick<LibraryObject<T>, 'getRowId'>; const a: ScrollProps<number> = {}; // ok const b: ScrollProps<number> = { scrollToItem: 6, getRowId: (row) => '' }; // ok const c: ScrollProps<number> = { getRowId: (row) => '' }; // ok const d: ScrollProps<number> = { scrollToItem: 6 }; // error - 符合预期
但将该类型与已继承库类型的现有类型组合时,TypeScript校验逻辑出现异常:当传入scrollToItem的同时也传入了被扩展类型的其他属性时,TypeScript不再强制要求传入getRowId。复现代码如下:
type LibraryObject<T> = { getRowId?: (row: T) => string; otherProps?: boolean; }; type ScrollProps<T> = | ({ scrollToItem: T } & Required<Pick<LibraryObject<T>, 'getRowId'>>) | Pick<LibraryObject<T>, 'getRowId'>; type MasterType<T> = LibraryObject<T> & ScrollProps<T>; const a: MasterType<number> = {}; // ok const b: MasterType<number> = { scrollToItem: 6, getRowId: (row) => '' }; // ok const c: MasterType<number> = { getRowId: (row) => '' }; // ok const d: MasterType<number> = { scrollToItem: 6 }; // error - 符合预期 const e: MasterType<number> = { scrollToItem: 6, otherProps: true }; // 无报错,不符合预期
这个问题的本质是两个TypeScript类型系统特性共同作用的结果:
- 对象类型默认是开放的:TS的对象类型不会禁止类型中未声明的属性,只要所有必填属性满足要求即可。我们常说的"多余属性报错"只是针对对象字面量直接赋值的特殊检查(excess property checking),不是类型系统的核心规则。
- 联合类型匹配不会自动推断互斥属性:定义的
ScrollProps第二个分支,仅声明了getRowId为可选,没有显式禁止scrollToItem属性存在。
当ScrollProps单独使用时,第二个分支的类型只有getRowId?: (row:T)=>string,传入scrollToItem时会触发多余属性检查,因此能正确报错。但当交叉LibraryObject<T>生成MasterType后,联合类型的第二个分支会被展开为{ getRowId?: (row:T)=>string; otherProps?: boolean },此时传入otherProps: true会让TS判定对象匹配第二个分支,scrollToItem:6不会触发多余属性拦截,最终绕过了getRowId的必填校验。
给联合类型的第二个分支显式添加互斥属性声明,将scrollToItem标记为never类型,明确告知TS该分支下不允许存在scrollToItem属性:
type ScrollProps<T> = | ({ scrollToItem: T } & Required<Pick<LibraryObject<T>, 'getRowId'>>) | Pick<LibraryObject<T>, 'getRowId'> & { scrollToItem?: never }; type MasterType<T> = LibraryObject<T> & ScrollProps<T>; // 校验逻辑恢复正常 const e: MasterType<number> = { scrollToItem: 6, otherProps: true }; // 报错:类型 "{ scrollToItem: number; otherProps: boolean; }" 中缺少属性 "getRowId"
修复后无论是否传入otherProps等其他库属性,只要存在scrollToItem,TS就会强制要求传入getRowId,完全符合预期约束。
内容的提问来源于stack exchange,提问作者HaveSpacesuit

