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.
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.
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.
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.
// 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!
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 inlib.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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With