Intersection types with Typescript
Solution 1:
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
Solution 2:
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
Solution 3:
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