I have two union types:
type TypeA = string | number;
type TypeB = string | boolean;
I create an intersection type using the above:
type Combined = TypeA & TypeB;
Naturally, the Combined
type will only be of type string
, since it's the only type that intersects between TypeA
and TypeB
.
But if I change the union of TypeB
and add a Date type, I get an unexpected behavior, e.g.:
type TypeA = string | number;
type TypeB = string | boolean | Date;
Create a new intersection type:
type Combined = TypeA & TypeB;
If I check the signature of the type, it looks like this:
type Combined = string | (string & Date) | (number & Date);
My question is why is this happening? Is this expected? I thought it would be of type string as it is the only type that intersects.
TypeScript allows us to not only create individual types, but combine them to create more powerful use cases and completeness.
The concept of discriminated unions is how TypeScript differentiates between those objects and does so in a way that scales extremely well, even with larger sets of objects. As such, we had to create a new ANIMAL_TYPE property on both types that holds a single literal value we can use to check against.
PHP 8.1's implementation of Intersection Types is called "pure" Intersection Types because combining Union Types and Intersection Types in the same declaration is not allowed. Intersection Types are declared by combining the class/interface names with an & sign.
It's not a bug; it's there to allow a (not well-publicized) feature called branded or tagged primitives.
It's pretty much impossible to have a value v
at runtime which is both a string
(the primitive, where typeof v === "string"
, not a String
wrapper object) and a Date
. In some sense, the type string & Date
really is the same as never
, since you can think of a TypeScript type as the set of all appropriate JavaScript values. If there are no string & Date
values, and there are no never
values, then those types are logically equivalent.
So it would be plausible if the compiler were to eagerly reduce an intersection like string & Date
to never
. It already does this for intersections of incompatible unit types like "a" & 0
, and intersections of incompatible primitive types like string & number
, as implemented in microsoft/TypeScript#31838. So why doesn't this happen when we intersect object types with primitives?
The reason is to allow a feature called branded primitives which emulate nominal typing for primitives in TypeScript (mentioned in this FAQ entry).
TypeScript mostly has only structural typing and type aliases; if two types have the same structure but different names, they are the same type. If you give an existing type a new name via a type alias, they are the same type. Often that's just what you want, but sometimes you'd like to come up with two types which, while identical at runtime, need to be distinguished in your code because you don't want a developer to accidentally mix them up.
For example (and this might be a silly example):
type Username = string;
type Password = string;
declare function login(username: Username; password: Password): void;
Here we would like to make sure that someone who writes TypeScript code that calls login
does not accidentally put the password in for the username or vice versa. It would be nice if the above type aliases actually prevented you from doing this:
declare function getUsername(): Username;
declare function getPassword(): Password;
login(getPassword(), getUsername()); // no error, OOPS
but it doesn't. The Username
and Password
types are both just string
. The fact that we are using different names doesn't change that. So sometimes TypeScript developers would like their types to be nominal, to catch errors like the above.
There's a very long discussion in microsoft/TypeScript#202 about how to get nominal typing. One way to do it for primitive types is with "branding", where you add a "phantom" distinguishing property that exists only in the type system and not at runtime. So you could change the above to:
type Username = string & { __brand: "Username" };
type Password = string & { __brand: "Password" };
and suddenly you'd get the desired error here:
login(getPassword(), getUsername()); // error! Password not assignable to Username
Of course actually convincing the compiler that a particular string
really is a Username
or a Password
consists of lying via something like a type assertion:
function toUsername(x: string): Username {
return x as Username; // <-- lying
}
But of course we know that at runtime you can't really have a value of type Username
or Password
, since if you take a primitive string
it won't have a __brand
property. If the compiler decided to eagerly reduce these impossible-at-runtime branded types to never
, they would break completely. It would be even worse than just using the primitives, since nothing would be assignable to them, but they'd still be indistinguishable and allow mixups:
login(getPassword(), getUsername()); // no error again
And while this feature might not be very savory, it's being used in existing real-world TypeScript code, including the TypeScript source code for the TypeScript compiler itself. Reducing branded primitives to never
would break too many people for it to be worth it.
Playground link to code
This is the expected behavior, intersection of primitives are simplified to never
, while intersections of primitives with object types are not simplified (to enable thigs such as branded primitive types). Date
is not a primitive, it is an object type defined in lib.d.ts
Give this, and the fact that typescript normalizes unions and intersections by moving the intersection inside we get
type TypeA = string | number;
type TypeB = string | boolean;
type Combined = TypeA & TypeB;
=> (string | number) & (string | boolean)
// Distributivity kicks in
=> (string & string) | (string & boolean) | (number & string) | (number & boolean)
// intersection simplification
=> string | never | never | never
// never melts away in a union
=> string
While in the second case we get
type TypeA = string | number;
type TypeB = string | boolean | Date;
type Combined = TypeA & TypeB;
=> (string | number) & (string | boolean | Date)
// Distributivity kicks in
=> (string & string) | (string & boolean) | (string & Date) | (number & string) | (number & boolean) | (number & Date)
// intersection simplification, but nothing is done about Date and any primitive
=> string | never | (string & Date) never | never | (number & Date)
// never melts away in a union
=> string
If you want to extract any type from TypeA
that is present in TypeB
you might be better off using the Extract
conditional type
type TypeA = string | number;
type TypeB = string | boolean | Date;
type Combined = Extract<TypeA, TypeB>;
Playground Link
That's because Date is not a primitive type. incompatible primitive types reduce to never
.
type TypeA = string | number;
type TypeB = string | boolean;
type Combined = TypeA & TypeB;
// string | (string & number) | (string & boolean)
// => string | never | never
// => string
It is easier for a developer to debug without that reduction. You can try with your own class and it will have the same behaviour.
Playground
I looked for a more detailed answer and found this one: https://stackoverflow.com/a/53545038/14438744
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