Home > Software engineering >  Why do two variables of the same type behave differently depending on how they were produced?
Why do two variables of the same type behave differently depending on how they were produced?

Time:06-13

Let's say I have some object interface, one property of which is a restricted set of numbers

interface ObjectWithRestrictedNumbers {
    num: 0 | 1 | 2;
}
const someObjects: ObjectWithRestrictedNumbers[] = [
  { num: 0 },
  { num: 1 },
  { num: 2 },
];

Elsewhere, I find it convenient to extract just that property into an function-scoped array and do things like swap some of its values

const someNums = someObjects.map((obj) => obj.num);
// (0 | 1 | 2)[]
let partOfSomeNums = [someNums[1], someNums[0]];
// no problem
[someNums[0], someNums[1]] = partOfSomeNums;

I go about my business using 0 as a sentinel value but later decide that's silly, I should make the property optional instead

interface ObjectWithOptionalRestrictedNumber {
    num?: 1 | 2 ;
}
const someObjects2: ObjectWithRestrictedNumbersOrUndefined[] = [
  {},
  { num: 1 },
  { num: 2 },
];

I now have to update the code that extracts just that property. It's still convenient to use 0 as a sentinel in just this one place, so I transform undefined values into 0s

// type of someNums2 is (0 | 1 | 2)[], same as someNums
const someNums2 = someObjects2.map((obj) =>
  typeof obj.num === "undefined" ? 0 : obj.num
);

But now, if I extract part of my mapped array, the type is numbers[], which means my previously-working swap operation is going to fail with Type 'number' is not assignable to type '0 | 1 | 2'.

// numbers[]
let partOfSomeNums2 = [someNums2[1], someNums2[0]];
// error
[someNums2[0], someNums2[1]] = partOfSomeNums2;

I can force it with as, but why should I need to?

[someNums2[0], someNums2[1]] = partOfSomeNums2 as (0 | 1 | 2)[];

Playground

I haven't hit on what's fundamentally different about these examples, so it could be that my example code could be more minimal. I'll try to reduce it down if someone can explain what's going on.

CodePudding user response:

I think TypeScript reads:

let partOfSomeNums2 = [someNums2[1], someNums2[0]];

like

let partOfSomeNums2 = [0, 1];

In the second line: partOfSomeNums2 is a number[], just like the first line.

You can use:

let partOfSomeNums2 = [...someNums2.slice(1, 2), ...someNums2.slice(0, 1)];

Or:

let partOfSomeNums2: (0 | 1 | 2)[] = [someNums2[1], someNums2[0]];

I think your someWorkingFunction in your playground works because the type 0 | 1 | 2 is directly extracted from an enum, in contrary of your not working code, where the value 0 is hard coded.

EDIT

I would use this solution: Playground

Using this type: type SmallNumber = 0 | 1 | 2; And then, use this type in your mapping function:

const someNums2 = someObjects2.map((obj): SmallNumber =>
    typeof obj.num === "undefined" ? 0 : obj.num
);

CodePudding user response:

This is a consequence of the difference between widening and non-widening literal types, as implemented in microsoft/TypeScript#11126. It's understandably confusing, because IntelliSense does not display these types any differently; it's an invisible attribute of the type. For example, you can't tell if a type displayed as 0 | 1 | 2 is widening or not.


Generally speaking, a literal type that only occurs in an expression (and not in a type) will automatically widen to its corresponding base primitive type (so 0 will become number) when inferred in a place where the value could be reassigned (such as the element of a non-readonly array or tuple). So for this:

const nums = ([] as { num?: 1 | 2 }[]).map(n => typeof n.num === "undefined" ? 0 : n.num)
// const nums: (0 | 1 | 2)[]

The type of nums is an array of widening literal types, because the type 0 comes from the expression 0 and the type 1 | 2 comes from the expression n.num, but nowhere in that map() method call are such types explicitly written as types. And so 0 | 1 | 2 widens to number when inferred in a place that can be reassigned, such as an array literal:

let tuple = [nums[0], nums[1]];
// let tuple: number[]

On the other hand, a literal type that occurs explicitly in a type (not just an expression*) will not automatically widen in a similar situation. So for this:

const nums: (0 | 1 | 2)[] = [];
// const nums: (0 | 1 | 2)[];

The type of nums is an array of non-widening literal types, because the type 0 | 1 | 2 comes from the type annotation for nums. And so 0 | 1 | 2 does not widen to number when inferred in a place that can be reassigned, such as an array literal:

let tuple = [nums[0], nums[1]];
// let tuple: (0 | 1 | 2)[];

If you want a widening literal type to become non-widening, you can add an explicit type somewhere as a hint. Like explicitly typing one of the values in the expression:

const nums = ([] as { num?: 1 | 2 }[]).map(n => typeof n.num === "undefined" ? 0 as 0 : n.num)
// const nums: (0 | 1 | 2)[]
let tuple = [nums[0], nums[1]];
// let tuple: (0 | 1 | 2)[];

or explicitly specifying the type parameter for the call to map()

const nums = ([] as { num?: 1 | 2 }[]).map<0 | 1 | 2>(n => typeof n.num === "undefined" ? 0 : n.num)
// const nums: (0 | 1 | 2)[]
let tuple = [nums[0], nums[1]];
// let tuple: (0 | 1 | 2)[];

or any of the methods discussed in this comment inside microsoft/TypeScript#12267.

Playground link to code

  • Related