Skip to content
Advertisement

How to conditionally add a key to an object?

Consider these types:

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

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

I want to convert an object of type A into B by overwriting some keys and adding keys conditionally depending on whether the original object has them:

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 throws this error:

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'.

Why is it inferring b in this way? Is there a way to work around it?

Advertisement

Answer

It’s a combination of two minor design limitations and one major design limitation of TypeScript, and you’d be better off refactoring or using a type assertion to move forward.


First is 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 type and you’re checking its discriminant property. In your case, A is not a discriminated union (it’s not a union at all), so this doesn’t happen. Observe:

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

There is a newer open request at microsoft/TypeScript#42384 to address this limitation. But for now, anyway, this prevents your a.b check from having any implication on the observed type of a when you spread it into b.

You could write your own custom type guard function which checks a.b and narrows the type of 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
}

The next problem is that the compiler does not see an object-whose-property-is-a-union as equivalent to a union-of-objects:

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.

Any kind of narrowing behavior where the compiler thinks of a as “either something with a string-valued b, or something with an undefined b” would rely on this sort of equivalence. The compiler does understand this equivalence in certain concrete cases thanks to smarter union type checking support introduced in TS 3.5, but it does not happen at the type level.


Even if we change A to EquivalentA and the a.b check to isBString(a), you still have the error, though.

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

And that’s the big problem: fundamental limitations of control flow analysis.

The compiler checks for certain commonly used syntactic structures and tries to narrow the apparent types of values based on these. This works well with structures like if statements, or logical operators like || or &&. But the scope of these narrowings is limited. For if statements this would be the true/false code blocks, whereas for logical operators this is the expression to the right of the operator. Once you leave these scopes, all control flow narrowing has been forgotten.

You cannot “record” the results of control flow narrowing into a variable or other expression and use them later. There’s just no mechanism to allow this to happen. (See 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, which asks for support for this on new object literals.

It seems that you expect the code

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

to work because the compiler should perform something like the following analysis:

  • The type of a is { a: string, b: string } | {a: string, b?: undefined}.
  • If a is {a: string, b: string}, then (barring any weirdness with falsy "" values), {...a, a: 1, ...isBString(a) && {b: Number(a.b) } will be a {a: number, b: number}.
  • If a is {a: string, b?: undefined}, then “{…a, a: 1, …isBString(a) && {b: Number(a.b) }will be a{a: number, b?: undefined}`
  • Therefore this expression is a union {a: number, b: number} | {a: number, b?: undefined} which is assignable to B.

But this does not happen. The compiler does not look at the same code block multiple times, imagining that some value has been narrowed to each possible union member in turn, and then collecting the result into a new union. That is, it does not perform what I call distributive control flow analysis; see microsoft/TypeScript#25051.

This could almost certainly never happen automatically, because it would be prohibitively expensive for the compiler to simulate that every value of a union type is of every possible narrowing everywhere. You can’t even ask the compiler to do it explicitly (that’s what microsoft/TypeScript#25051 was about).

The only way to get control flow analysis to happen multiple times is to give it multiple code blocks:

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
  }

Which, at this point, is really too ugly and far away from your original code to be plausible.


You could, as the other answer mentioned, use a different workflow entirely. Or you could use a type assertion somewhere to make the compliler happy. For example:

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

Here we are asking the compiler to pretend that a doesn’t even have a b property when we spread it into the new object literal. Now the compiler does not even consider the possibility that the resulting b might be of type string, and it compiles without error.

Or even simpler:

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

In cases like this where the compiler is unable to verify type safety of something where you are sure it is safe, a type assertion is reasonable. This shifts the responsibility for such safety away from the compiler and onto you, so be careful.

Playground link to code

User contributions licensed under: CC BY-SA
3 People found this is helpful
Advertisement