Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Missing flow in type narrowing with "unknown" datatype in typescript

Tags:

typescript

I was playing with the unknown type. The minimal example is:

let value: unknown;
if (value === undefined || value === null || value === '') {
  typeof value;    //  ""|null|undefined  fine
} else if (typeof value === 'object') {
  typeof value;     // object | null  huh? 
}

Is this a design limitation or a bug in typescript? I could not find something like that in the issue tracker.

like image 523
HolgerJeromin Avatar asked Apr 27 '20 15:04

HolgerJeromin


People also ask

How do you handle unknown types in TypeScript?

In Typescript, any value can be assigned to unknown, but without a type assertion, unknown can't be assigned to anything but itself and any. Similarly, no operations on an unknown are allowed without first asserting or restricting it down to a more precise type.

What is difference between unknown and any in TypeScript?

unknown is the type-safe counterpart of any . Anything is assignable to unknown , but unknown isn't assignable to anything but itself and any without a type assertion or a control flow based narrowing. Likewise, no operations are permitted on an unknown without first asserting or narrowing to a more specific type.

What is type narrowing in TypeScript?

TypeScript follows possible paths of execution that our programs can take to analyze the most specific possible type of a value at a given position. It looks at these special checks (called type guards) and assignments, and the process of refining types to more specific types than declared is called narrowing.

How do you cast an unknown string?

// Example to convert unknown type to string: using String Constructor let random: unknown = 'Hello World! '; let stringValue: string = String(random); Caveat: Be careful when using String constructor, as anything will be converted into a string, whether it is a string, a boolean, a number, or even a function!


1 Answers

I'm going to call this a design limitation/missing feature: TypeScript doesn't currently have subtraction types or negated types, and although there was at some point work being done here (microsoft/TypeScript#29317), it was shelved. That means there's no general way to take two types A and B and represent the type "everything assignable to A but not to B", a.k.a., A - B or A \ B or A & not B, etc. If A and B happen to be union types where every member of B is a member of A then you can use a conditional type like Exclude<A, B>, but that only works for union types.

Since unknown is not a union type, there is no way in TypeScript to represent the type unknown & not (undefined | null | ""), which is what you want the compiler to narrow value to in the else clause. No narrowing happens at all in the else clause, and value stays unknown. Sorry. 😢

Since value is still of type unknown in the else clause, the check typeof value === 'object' can only narrow from unknown to the types where typeof value === 'object' is true: that's object | null.


That might not be satisfying: why is the unknown type treated as a non-union, as opposed to being represented internally as, say, {} | null | undefined or object | string | number | boolean | null | undefined? After all, you'd be hard-pressed to find a value of type unknown which is not assignable to one of those other types ("hello" is assignable to {}, for example). And indeed, if you change the annotated type of value to object | string | number | boolean | null | undefined, you'll get the behavior you want:

let value: object | string | number | boolean | null | undefined;
if (value === undefined || value === null || value === '') {
    typeof value;    //  ""|null|undefined 
} else if (typeof value === 'object') {
    typeof value;     // object
}

So why doesn't unknown just act like that all the time? The closest thing to a definitive answer comes in a comment to a related issue by one of the TS leads:

There was a long internal discussion over whether or not we even needed unknown, because it has an identical domain to {} | null | undefined - it was even proposed to just write that as a type alias in lib.d.ts. But we explicitly wanted a type that didn't distribute over conditional types because that expansion typically made things worse rather than better.

Having unknown treated like a union causes weird downstream behavior with distributive conditional types. Any language feature that slices types into unions of subtypes has observable effects, some of which might not be what you want to see. I've seen people stumble into the fact that boolean is actually defined as the union true | false and that it splits into pieces when people expect it not to. It's a tricky thing in general, and any choice made tends to be a trade-off that leaves at least some people unhappy.


In your case you could either change the annotated type of value from unknown to a better-behaved union... or, if you can't change it, you could use an assertion function to "prepare" value for the control-flow analysis to follow:

function toUnion(
    x: unknown
): asserts x is object | string | number | boolean | null | undefined { }

And then you call toUnion(value) before the check:

let value: unknown;
toUnion(value); // does nothing at runtime but transforms value to the union type
if (value === undefined || value === null || value === '') {
    typeof value;    //  ""|null|undefined 
} else if (typeof value === 'object') {
    typeof value;     // object
}

Okay, hope that helps; good luck!

Playground link to code

like image 61
jcalz Avatar answered Nov 15 '22 05:11

jcalz