TypeScript - 展开运算符和可选字段。如何推断正确类型
假设你有一个具有非空必填字段的对象:
interface MyTypeRequired {
value: number;
}
并且你想用另一个对象的字段更新它,并且有一个可选字段:
interface MyTypeOptional {
value?: number;
}
所以你继续去创建一个函数:
function createObject(a: MyTypeRequired, b: MyTypeOptional) {
return { ...a, ...b };
}
这个函数的推断返回类型是什么?
const a = createObject({ value: 1 }, { value: undefined });
实验表明,它将符合 MyTypeRequired 接口,即使第二个扩展具有一个可选字段。
如果我们改变顺序,推断的类型不会改变,即使运行时类型会不同。
function createObject(a: MyTypeRequired, b: MyTypeOptional) {
return { ...b, ...a };
}
为什么 TypeScript 会有这样的行为,以及如何解决这个问题?
答案#1
对象扩展类型的解析方式如下:
调用和构造签名将被移除,只有非方法属性会被保留,对于具有相同名称的属性,将使用最右边属性的类型。
具有 通用展开表达式 的对象字面量现在 生成交叉类型,类似于 Object.assign 函数和 JSX 字面量。例如:
这运行良好,直到你对最右边的参数有一个可选属性。{value?: number; } 可以意味着 1) 缺失属性 或 2) 属性的值为 undefined —— TS 无法通过可选修饰符 ? 来区分这两种情况。我们来看一个例子:
const t1: { a: number } = { a: 3 }
const u1: { a?: string } = { a: undefined }
const spread1 = { ...u1 } // { a?: string | undefined; }
const spread2 = { ...t1, ...u1 } // { a: string | number; }
const spread3 = { ...u1, ...t1 } // { a: number; }
有道理,属性键a可以设置也可以不设置。我们别无选择,只能用undefined类型来表示后者。
a 的类型由最右边的参数决定。所以如果 u1 中存在 a,它将是 string,否则扩展运算符会从第一个参数 t1 中取 a,其类型为 number。因此 string | number 也是有道理的。注意:这里没有 undefined。TS 假设该属性根本不存在,或者它是 string。如果我们给 a 一个显式的 undefined 属性值类型,结果会不同:
const u2 = { a: undefined }
const spread4 = { ...t1, ...u2 } // { a: undefined; }
最后一个很简单:t1 的 a 会覆盖 u1 的 a,所以我们得到 number。
我不会指望会有修复,所以这里有一个可能的变通方法,使用一个单独的 Spread 类型和函数:
type Spread<L, R> = Pick<L, Exclude<keyof L, keyof R>> & R;
function spread<T, U>(a: T, b: U): Spread<T, U> {
return { ...a, ...b };
}
const t1_: { a: number; b: string }
const t2_: { a?: number; b: string }
const u1_: { a: number; c: boolean }
const u2_: { a?: number; c: boolean }
const t1u2 = spread(t1_, u2_); // { b: string; a?: number | undefined; c: boolean; }
const t2u1 = spread(t2_, u1_); // { b: string; a: number; c: boolean; }
希望这有道理!这是上述代码的示例。
答案#2
警告!这是我对发生情况的解释(理论)。尚未得到确认。
这个问题似乎是处理可选字段的预期行为。
当你定义这种类型时:
interface MyTypeOptional {
value?: number;
}
TypeScript 期望该值的类型为 number | never。
所以当你涂抹它时
const withOptional: MyTypeOptional = {};
const spread = { ...withOptional };
展开对象的类型应为 { value: never | number } 或 { value?: number }。
不幸的是,由于可选字段与未定义字段的模糊用法,TypeScript 推断扩展对象如下:
value?: number | undefined因此,可选字段类型在展开时被转换为 number | undefined。
不合逻辑的是,当我们展开一个非可选类型时,它似乎会覆盖可选类型:
interface MyTypeNonOptional {
value: number;
}
const nonOptional = { value: 1 };
const spread2 = { ...nonOptional, ...withOptional }
看起来像是一个 bug?但不是,在这里 TypeScript 并不会将可选字段转换为 number | undefined。在这里它会将其转换为 number | never。
我们可以通过将可选改为显式 |undefined 来确认这一点:
interface MyTypeOptional {
value: number | undefined;
}
这里,以下传播:
const withOptional: MyTypeOptional = {} as MyTypeOptional;
const nonOptional = { value: 1 };
const spread2 = { ...nonOptional, ...withOptional }
将被推断为 { value: number | undefined; }
当我们将其更改为可选或未定义时:
interface MyTypeOptional {
value?: number | undefined;
}
它又坏了,并被推断为 { value: number }
那么,什么是变通办法?
我想说,在 TypeScript 团队修复它们之前,不要使用可选字段。
这有几个含义:
- 如果你需要在对象的键未定义时将其省略,请使用
omitUndefined(object) - 如果你需要使用 Partial,请通过创建工厂来限制其使用:
<T>createPartial (o: Partial<T>) => Undefinable<T>。这样,createPartial<MyTypeNonOptional>({})的结果将被推断为{ value: undefined }或Undefinable<MyTypeNonOptional>。 - 如果你需要某个字段的值为
unset,请使用null。
关于可选字段的这个限制,有一个未解决的问题:https://github.com/microsoft/TypeScript/issues/13195
