Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Intersection types with Typescript

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.

like image 203
gmwill934 Avatar asked Oct 30 '20 18:10

gmwill934


People also ask

Is it possible to combine types in TS?

TypeScript allows us to not only create individual types, but combine them to create more powerful use cases and completeness.

What is discriminated unions in TypeScript?

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.

What are pure intersection types?

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.


Video Answer


3 Answers

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

like image 86
jcalz Avatar answered Oct 14 '22 05:10

jcalz


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

like image 5
Titian Cernicova-Dragomir Avatar answered Oct 14 '22 05:10

Titian Cernicova-Dragomir


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

like image 2
devdgehog Avatar answered Oct 14 '22 07:10

devdgehog