Demystifying Typescript Unions And Intersections

September 12, 2022

Typescript uses a structural type system which means when comparing types, Typescript looks at the structure of the types to decide if they are “assignable” to each other.

We can think of a "type" as a set of all the values that can be assigned to it. In this blog, we will be conceptualizing the type system as a universe that consists of all the possible types and their values.

If you were to group all the string values in the universe, you could define them using something like

  
type MyType = string;
  

All the string values in the universe are now “assignable” to the type MyType.

If you want to limit your new type to a specific set of string values, you would explicitly state them in your type definition.

  
type MyOtherType = 'hello' | 'world';
  

Only two values in the type universe are now “assignable” to the type MyType.

Before we move on, let’s get a quick refresher on Javascript primitive and object data types as we’ll use these terms later.

From the MDN Docs:

JS Primitives

A primitive (primitive value, primitive data type) is data that is not an object and has no methods or properties. There are 7 primitive data types: string, number, bigint, boolean, undefined, symbol, and null.

JS Object

Objects can be seen as a collection of properties. With the object literal syntax, a limited set of properties are initialized; then properties can be added and removed. Property values can be values of any type, including other objects, which enables building complex data structures. Properties are identified using key values. A key value is either a String value or a Symbol value.

  
const foo = "bar"; // primitive
const fooNum = 2; // primitive
const fooNull = undefined; // primitive

const fooObj = {}; // object
const fooNumObj = Number(); // object
const fooArr = []; // object
  

Primitive Type

A primitive type in Typescript is a type representing a javascript primitive. For example, all the string values in javascript can be assigned to the string primitive type.

Unit Type

A unit type is a type that has only one value that can be assigned to it.

Every primitive value in javascript is also a unit type. “4”, 56, “cat”, undefined, true, etc can be considered as a unit type with only one value.

  
type Cat = 'cat'; // only "cat" is assignable to this type
type Number4 = 4; // only 4 is assignable to this type
type Falsy = false; // only false is assignable

const myVar: Cat = "cat"; // no value other than "cat" can be assigned
const myOtherVar: Falsy = false; // no value other than false can be assigned
  

In the above examples, each type can only be assigned one value which is the type itself. The type Cat can only be assigned to the value "cat" , similarly, the type Number4 can only be assigned to the value 4 . These types are thus called unit types.

Let’s look at the following type.

  
type MyType = 'hello' | 'world';
  

We can also think of it as if the type MyType is made up of the union of two unit typeshello’ and ‘world’;

The only value that can be assigned to union type "hello" is value "hello" and to union type "world" is value "world" . So a union of these unit types is also the union of their values which can now be assigned to the new type MyType.

  
type AllStrings = string; // a primitive type
type AString = "typescript"; // a unit type
  

Typescript type system is all about “assignability”. It doesn’t care about the names of the type as long as the value is assignable to another type. This is also referred to as “duck typing”

"If it looks like a duck, walks like a duck, and quacks like a duck, then it is a duck."

Type Unions

  
type Animal = 'lion' | 'tiger';
type Insect = 'ant' | 'bee';
  

The pipe symbol | is similar to an “or” operator. In the context of a type universe, any type to the left or the right side of the | symbol is assignable to the new type.

The | operator works exactly the same as a union of two sets.

The union of two sets is a set containing all elements that are in A or B (possibly both). For example, {1,2} ∪ {2,3} = {1,2,3}

The values (from the universe) that can be assigned to type Animal are ‘lionortiger’.

The values (from the universe) that can be assigned to type Insect are ‘antorbee’.

If we were to create a new type called Living and wanted both the values of Animals and Insect to be assignable to it. You’d define it like this.

  
type Living = Animal | Insect;
  

This means any value assignable to type Animal or any value of type Insect is also assignable to the type Living.

  
const pet: Living = 'lion'; ✅
const anotherPet: Living = 'bee'; ✅
  

Quiz #1

What are all the values that are assignable to each of the following types?

  
type Animal = 'lion' | 'tiger';
type Insect = 'ant' | 'bee';
type Q11T = any | Animal | Insect;
type Q12T = never | Animal;
type Q13T = any | never;
  

Type Intersection

The ampersand symbol & is similar to an “and” operator. In the context of a type universe, any value that can be assigned to both the left and the right side of the & symbol is assignable to the new type.

The & operator works exactly the same as an intersection of two sets.

The intersection of two sets A and B , denoted by A ∩ B , consists of all elements that are both in A and B. For example, {1,2} ∩ {2,3} = {2}

Continuing our previous example.

  
type Animal = 'lion' | 'tiger';
type Insect = 'ant' | 'bee';
type Domestic = 'bee' | 'cow';

type DomesticInsect = Insect & Domestic;
  

The unit types on the left side are ‘ant’ or ‘bee’ while the unit types on the right side are ‘bee’ or ‘cow’. We need to find a value in the universe that can be assigned to both Insect and Domestic. There’s only one such value and that is 'bee' as it is assignable in both.

This can also be evaluated by applying distributive law.

  
type Animal = 'lion' | 'tiger';
type Domestic = 'bee' | 'cow';
type DomesticAnimal = Animal & Domestic;
  

So the new type DomesticInsect has only one value and that is 'bee'.

What about the following types?

  
type Animal = 'lion' | 'tiger';
type Domestic = 'bee' | 'cow';
type DomesticAnimal = Animal & Domestic;
  

Since there is no common valu sides, DomesticAnimal is a type with no assignable value and the type with no value is called the never type.

Let’s break it down further by applying distribution.

  
type DomesticAnimal = ('lion' | 'tiger') & ('bee' | 'cow');
type DomesticAnimal = ('lion' & 'bee') | ('lion' & 'cow') | ('tiger' & 'bee') | ('tiger' & 'cow');
type DomesticAnimal = never | never | never | never;
type DomesticAnimal = never;
  

Note that if you intersect a subset type with a superset, then the resulting type is the subset itself. Can you think why?

For example, In the example below, Admin is a more specific type (also called a narrower type or a subset) compared to Name, which is a type of all string values.

  
type Name = string;
type Admin = 'dan' | 'joe';

type E = Name & Admin;
// type E = 'dan' | 'joe';
  

Type string consists of all the possible string values in the universe including dan and joe. Whereas the type Admin only consists of two values dan and joe . When we apply the & operator to find the intersection, the only two values common in both the types are dan and joe.

The same principle applies when you take the union of a subset type with a superset, then the resulting set is the superset.

  
type Name = string;
type Admin = 'dan' | 'joe';

type E = Name | Admin;
// type E = string;
  

The above type E evaluates the type string as it includes all the possible string including the values and dan and joe

Quiz #2

What values can be assigned to each of the following types?

  
type Q21T = any & never;
type Q22T = 'sparrow' & 'pigeon';
type Q23T = 'sparrow' & never;
  

Object Types

In the type universe analogy, an object type like interface User { name: string; } is a set of all the objects in the universe that have a field called name.

  
interface User {
  name: string;
}

interface UserWithAge {
   name: string;
   age: number;
}

let myUser: User = { name: 'John' }; 
let otherUser: UserWithAge = { name: 'Jim', age: 25 };

// any object of type UserWithAge is also assignable to User
// as it also has a `name` field of type string.
myUser = otherUser; // ✅
  

How many such objects would exist in the universe? Infinite.

So { name: 'John' } , { name: 'Dog', type: 'Animal'  } , { name: 'Microsoft', employees: 200000 } , { name: 'Henna', id: 3, age: 20 } are all assignable to the type User above.

One thing that confused me in my early days with Typescript was how come an object of type UserWithAge is assignable to User, but assigning a literal value of { name: 'Jim', age: 25 } to a variable of type User does not work.

  
interface User {
  name: string;
}

let myUser: User = { name: 'John' }; // ✅

const otherUser: User = { name: 'Jim', age: 13 }; // ❌

myUser = otherUser; // ✅
  

It turns out typescript has a special rule for these scenarios. You might have come across a typescript error message that looks like Object literal may only specify known properties

This is the rule that prevents assigning an object literal ({ name: 'Jim', age: 13 }) to a type User if it has a property (age here) that is not present in the type. This rule only applies when you are assigning it to a an object literal.

Note that { name: 'Jim', age: 13 } is actually assignable to the type User as per the assignability rule, it’s just that you can’t assign it if it’s literal.

  
interface User {
  name: string;
}

interface UserWithAge {
   name: string;
   age: number;
} 

let myUser: User = { name: 'John' }; // ✅

const thirdUser: UserWithAge = { name: 'Jim', age: 13 };

myUser = thirdUser; // ✅ this works

let myOtherUser: User = { name: 'Jim', age: 13 }; 
// ❌ this doesn't since the object literal { name: 'Jim', age: 13 };
// has a property age which is not defined in User
  

This constraint was added to Typescript 1.6. Before that it used to work fine. You can see more details HERE

Type Unions

  
type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; }

function save(entity: User | Admin) {
    // save entity to database
}
  

Object union is where things get little tricky. Often times when doing a union of two object types, people do a union of their properties.

For example, for the User and Admin type above, the wrong way of doing their union is to do a union of their properties.

  
// This is an incorrect way ❌ to think of a union
// of two or more object types

type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; } 

type UserProperties = 'name' | 'id' | 'active';
type AdminProperites = 'name' | 'id' | 'role';

type PropertyUnion = UserProperties | AdminProperites;
// which is equivalent to
type PropertyUnion = 'name' | 'id' | 'active' | 'role';

// ❌ This is not the union of User | Admin
type Union = { name: string; id: string; active: boolean; role: string;  }
  

The correct way to do a union of two or more object types is to do a union of all the values that are assignable to these objects.

Remember the infinite values that are assignable to any object?

That means any value in the type universe that is assignable to User or any value that is assignable to Admin is assignable to User | Admin.

  
// User type value
const user_a: User = { name: "", id: "", active: false };

// Admin type value
const admin_e: Admin = { name: "", id: "", role: "read-only" };


const entity_i: User | Admin = user_a; // ✅ A user can be assigned to `User | Admin`
const entity_j: User | Admin = admin_e; // ✅ An admin can also be assigned to `User | Admin`
  

When you write the above code in a TS Intellisense, you would see that it only suggests two properties from the above union.

This is because when Typescript comes across the union symbol (|) it has to decide on what fields can be accessed without exactly knowing if the entity is a User or an Admin

Typescript Intellisense, in that case, only allows fields that exist on both the object types. After all, the entity argument could either be a value of type User or Admin. We don’t know specifically what it is until we narrow it down to either one of them.

Here’s the TS playground link to try it. If you try to access any other field if would complain.

This is a valid constraint enforced by the Typescript Intellisense since we can only guarantee that out of all the infinite possible values that are assignable to User | Admin the two fields that will be present in all of those values would be name and id as they are present in both the object types.

  
type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; } 
  

It can be misleading to think that since the Typescript Intellisense only suggests name and id fields in the resulting type (Name | Admin), the resulting type might only have those two fields.

Narrow Down The Type

Since we know that all the values of the User type will have a field named active which the values of type Admin won’t have, we can use this unique property to distinguish between the two types. This is also called Discriminated Union.

  
type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; }

function save(entity: User | Admin) {
  if ('active' in entity) {
     // TS treats the entity as a value of type User in this if block
     entity.active; // ✅ safe to use
  } else {
     // TS treats the entity as a value of type Admin in this else block
     entity.role; // ✅ safe to use
  }
}
  

Here’s the playground link to try the above.

Type Intersections

Let’s take the same example from above to go through object type intersection. The only difference is that this time in our function, we expect a User & Admin object rather than a union of the two.

  
type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; }

function save(entity: User & Admin) {

}
  

A very common but wrong way to think of object intersection is to simply get an intersection of the fields of the given types.

  
// This is an incorrect way ❌ to think of an intersection
// of two or more object types

type User = { name: string; id: string; active: boolean; }
type Admin = { name: string; id: string; role: string; } 

type UserProperties = 'name' | 'id' | 'active';
type AdminProperites = 'name' | 'id' | 'role';

type PropertyIntersection = UserProperties & AdminProperites;
// which is equivalent to
type PropertyIntersection = 'name' | 'id';

// ❌ This is not the intersection of User & Admin
type Intersection = { name: string; id: string;  }
  

The correct way to do an intersection of two or more object types is to do an intersection of all the values that are assignable to these objects.

Let’s take an example to understand this better. In the image below, we have a bunch of objects that are assignable to the type User, and in the image after it, we have objects that are assignable to the type Admin. Note that we cannot show all objects that can be assigned to each type as there are infinite of such objects.

  
type User = { name: string; id: string; active: boolean; }
  
  
type Admin = { name: string; id: string; role: string; }
  

In the image below, if you look at the highlighted objects, they are assignable to both User and Admin since they have properties of both types. We can ignore the extra properties on them.

Objects similar to the highlighted ones above are the result of an intersection of two object types. These objects have all the properties of the type User and also have all the properties of the Admin.

In other words, the intersection of two or more object types is a set of all the objects that have all the properties of all the objects being intersected.

This may seem a little weird because an intersection of the two types User and Admin looks like a union of their properties. But the premise of this behaviour is that intersection of two object types is not the intersection of the fields of the two types, it’s the result of the intersection of all the values assignable to a type with all the values assignable to the other type.

Since intersection always produces a narrower type, in this case also, the resulting intersection is a more specific type than the intersected object types as it has more properties than either of the parent types.

Typescript Intellisense also suggests all the properties of the intersected objects.

Here’s the playground link to try it.

Intersection With Any

If we look at the following typescript code.

  
type T1 = number;
type T2 = number & any;
  

Intuitively thinking, number & any should result in the type number since number seems to be a subset of type any. In other words, a number is a narrower type than any, which is the broadest type.

Interestingly, this is not how it works. I was surprised when I learned that.

Independently, a number is a narrower type than any. Since any is assignable to all values and number can only be assigned to numeric values. But the behaviour changes when they are brought into the same context. In other words, whenever a type (e.g. number) is intersected with any, any behaves as a subtype i.e a narrower type which results in the output of the that intersection to be any.

An intersection between a subtype and a supertype always results in the subtype.

  
type T1 = { a: number, b: number }
type T2 = any

type Intersection = any;
  

This might be a little unintuitive since we expect any to be a wider type rather than a narrower one.

Conclusion

If you love Javascript, Typescript will only make you love it even more. I’ve seen many success stories with using Typescript. We recently migrated a large project to typescript, and we haven’t seen a code syntax or a type error ever since we went to prod.

Hope you learned something from the article. If you have any questions or feedback free to reach out to me @ tushar.sharma@serverlessguru.com

Serverless Handbook
Access free book

The dream team

At Serverless Guru, we're a collective of proactive solution finders. We prioritize genuineness, forward-thinking vision, and above all, we commit to diligently serving our members each and every day.

See open positions

Looking for skilled architects & developers?

Join businesses around the globe that trust our services. Let's start your serverless journey. Get in touch today!
Ryan Jones
Founder
Book a meeting
arrow
Founder
Eduardo Marcos
Chief Technology Officer
Chief Technology Officer
Book a meeting
arrow

Join the Community

Gather, share, and learn about AWS and serverless with enthusiasts worldwide in our open and free community.