网站搜索

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