如何有条件地向对象添加键?

How to conditionally add a key to an object?

考虑这些类型:

type A = {
  a: string;
  b?: string;
}

type B = {
  a: number;
  b?: number;
}

我想通过覆盖一些键并根据原始对象是否有键有条件地添加键,将类型 A 的对象转换为 B:

const a: A = {
  a: '1',
  b: '2'
}

const b: B = {
  ...a,
  a: 1,
  ... a.b && {b: Number(a.b)}
}

// expected:
// const b: B = {
//   a: 1,
//   b: 2
// }

TypeScript 抛出此错误:

Type '{ b?: string | number | undefined; a: number; }' is not assignable to type 'B'.
  Types of property 'b' are incompatible.
    Type 'string | number | undefined' is not assignable to type 'number | undefined'.
      Type 'string' is not assignable to type 'number | undefined'.

为什么会这样推断b?有解决办法吗?

看来您编辑了问题并解决了您自己的问题! :) 除了最后的测试,我的代码和你的一样。


type A = {
  a: string;
  b?: string;
};


type B = {
  a: number;
  b?: number;
};

/* With more generic object types:
type A = {
  [id: string]: string;
};


type B = {
  [id: string]: number;
};
*/

const a: A = {
  a: '1',
  b: '2'
}

const b: B = {
  ...a,   
  a: 1,   
  ...(a.b && { b: Number(a.b) })
}

console.assert(b.a === 1, 'b.a');
console.assert(b.b === 2, 'b.b');
console.log(b);

运行 作为 tsc temp.ts && node temp.js 并输出:

{ a: 1, b: 2 }

它结合了 TypeScript 的两个次要设计限制和一个主要设计限制,您最好重构或使用 type assertion 继续前进。


首先是 microsoft/TypeScript#30506. In general, checking one property of an object will narrow the apparent type of that property but will not narrow the apparent type of the object itself. The only exception is if the object is of a discriminated union 类型,您正在检查其判别式 属性。在您的情况下, A 不是受歧视的联盟(它根本不是联盟),所以这不会发生。观察:

type A = {
  a: string;
  b?: string;
}
declare const a: A;
if (a.b) {
  a.b.toUpperCase(); // okay
  const doesNotNarrowParentObject: { b: string } = a; // error
}

microsoft/TypeScript#42384 有一个更新的开放请求来解决这个限制。但是现在,无论如何,当您将 b.

展开时,这会阻止您的 a.b 检查对观察到的 a 类型产生任何影响

您可以编写自己的自定义 type guard function 来检查 a.b 并缩小 a 的类型:

function isBString(a: A): a is { a: string, b: string } {
  return !!a.b;
}
if (isBString(a)) {
  a.b.toUpperCase(); // okay
  const alsoOkay: { b: string } = a; // okay now
}

下一个问题是编译器看不到一个对象-属性-是一个联合体等同于一个对象联合体:

type EquivalentA =
  { a: string, b: string } |
  { a: string, b?: undefined }

var a: A;
var a: EquivalentA; // error! 
// Subsequent variable declarations must have the same type.

编译器将 a 视为“具有 string 值的 b 的任何类型的缩小行为带有 undefined b" 的东西将依赖于这种等价。由于smarter union type checking support introduced in TS 3.5,编译器在某些具体情况下确实理解这种等价性,但它不会发生在类型级别。


即使我们将 A 更改为 EquivalentA 并将 a.b 检查更改为 isBString(a),您仍然会遇到错误。

const stillBadB: B = {
  ...a,
  a: 1,
  ...isBString(a) && { b: Number(a.b) }
} // error!

这就是大问题:control flow analysis 的基本局限性。

编译器检查某些常用的句法结构,并尝试根据这些结构缩小明显的值类型。这适用于 if 语句等结构,或 ||&& 等逻辑运算符。但这些缩小的范围是有限的。对于 if 语句,这将是 true/false 代码块,而对于逻辑运算符,这是运算符右侧的表达式。一旦离开这些范围,所有控制流缩小都将被遗忘。

您不能将控制流缩小的结果“记录”到变量或其他表达式中并在以后使用它们。只是没有允许这种情况发生的机制。 (参见 microsoft/TypeScript#12184 for a suggestion to allow this; it's marked as "Revisit" Update for TS4.4, this issue was fixed by a new control flow analysis feature but this fix doesn't do anything to help the current code, so I won't go into it). See microsoft/TypeScript#37224,它要求在新的对象文字上支持这一点。

看来你期待代码

const b: B = {
  ...a,
  a: 1,
  ...isBString(a) && { b: Number(a.b) }
} 

可以工作,因为编译器应该执行类似于以下分析的操作:

  • a的类型是{ a: string, b: string } | {a: string, b?: undefined}
  • 如果 a{a: string, b: string},那么(除非有任何奇怪的 "" 值),{...a, a: 1, ...isBString(a) && {b: Number(a.b) } 将是 {a: number, b: number}.
  • 如果a{a: string, b?: undefined},那么``{...a, a: 1, ...isBString(a) && {b: Number(a.b) }will be a{a:数字,b?:未定义}`
  • 因此这个表达式是一个联合 {a: number, b: number} | {a: number, b?: undefined} 可以分配给 B.

但这并没有发生。编译器不会多次查看同一个代码块,想象一些值已经依次缩小到每个可能的联合成员,然后将结果收集到一个新的联合中。也就是说,它不执行我所说的分布式控制流分析;参见 microsoft/TypeScript#25051

这几乎肯定永远不会自动发生,因为编译器模拟联合类型的每个值在任何地方都可能变窄的代价高得令人望而却步。您甚至不能要求编译器明确地执行此操作(这就是 microsoft/TypeScript#25051 的内容)。

让控制流分析发生多次的唯一方法是给它多个代码块:

const b: B = isBString(a) ? {
  ...a,
  a: 1,
  ...true && { b: Number(a.b) }
} : {
    ...a,
    a: 1,
    // ...false && { b: Number(a.b) } // comment this out
    //  because the compiler knows it's bogus
  }

在这一点上,这真的太丑陋了,而且与您的原始代码相去甚远,以至于不合理。


您可以像其他答案提到的那样,完全使用不同的工作流程。或者你可以在某处使用类型断言来让编译器满意。例如:

const b: B = {
  ...(a as Omit<A, "b">),
  a: 1,
  ...a.b && { b: Number(a.b) }
} // okay

这里我们要求编译器假装 a 甚至没有 b 属性 当我们将它扩展到新的对象文字中时。现在编译器甚至不考虑结果 b 可能是 string 类型的可能性,并且编译没有错误。

或者更简单:

const b = {
  ...a,
  a: 1,
  ...a.b && { b: Number(a.b) }
} as B

在这种情况下,编译器无法验证您确信它是安全的东西的类型安全,类型断言是合理的。这将这种安全的责任从编译器转移到您身上,所以要小心。

Playground link to code