Books

Monday, January 18, 2021

Union and Intersection Types in TypeScript

This blog post will look at Union and Intersection types in TypeScript. It is part of the Introduction to Advance Types in TypeScript series.

Union and Intersection types are useful features of TypeScript. But they can come across as a bit strange, especially to developers coming from more traditional languages like Java, C#, etc. Hence in this post, I will first start with a background section. This will help in developing the fundamental perspective needed to understand and appreciate union and intersection types in TypeScript.  

Background: A bit of Set Theory

The important thing is to see type systems from the perspective of Set Theory. 

What does this mean? 

I’ll explain.

First off, no need to be put off with the mention of set theory. Even though set theory itself is a deep and fundamental topic in mathematics, only the level of set theory learned in high school is required to follow along.

So how does set theory help in understanding types?

In set theory, we have the idea of a set and the elements in the set. A set usually outlines constraints that must be placed on an item before that item can be seen as an element of that set. For example, we can see a fruit basket as a set and the individual fruits in the basket as the element of the set. The constraint being that for an item to be in the basket, it needs to be a fruit.

Mathematicians have a couple of nation for representing sets, one of which is the Set-builder notation. Using the set-builder notation, a set is specified as a selection from a larger set, together with a predicate that specifies the condition an element selected from this larger set, must satisfy to be a member of the set being defined.

An example of such notation is shown below:

{x ∈ M : P(x)}.

This can be interpreted as a Set M, which contains element x, as long as x passes the constraints specified by predicate P.

Expressing our fruits basket in this notation may look like this:

{item ∈ Fruit Basket : As long as item is a fruit} 

We also have the idea of set operations in set theory, these are operations that can be performed on, and with sets. 

These include operations like finding the number of elements in a set, compliments of a set, union, and intersection operations, etc.

The idea here is to see a Type as a set, and a value as an element in that set. 

So instead of just seeing primitive types like stringnumberboolean as just facts of life in statically typed languages, the idea is to see them as delineating a set, while their values are the elements contained in that set.

This means seeing any string value as an element in the string set. The same goes for numbers and booleans. The interesting difference between the boolean type when seen as a set is that the element it can contain is finite. 

The value of a boolean can either be true or false, hence the boolean type, which we are seeing as a set, only contains 2 elements: the true and false values.

Using the set notation we can write out the boolean set as follows:

{x ∈ boolean: x == true || x == false }

What about types that are created via the definition of custom types? Can we apply the set theory idea also to those? Yes, we can. 

For example, given we have the following class Person definition:

Class Person {

private name: string;
private age: number;

constructor(givenName: string, givenAge: number) {
    this.name = givenName;
    this.age = givenAge; 
  }
}
Traditionally, most developers will see this just as a blueprint for creating objects. 

Applying the set theory perspective, we can see the Person class as a way to delineate a set (or type) called Person

Ok so if Person, the class is a set, where are the elements? 

The elements are the objects that one instantiates using this class constructor.
let joe: Person = new Person("Joe", 18);
let mary: Person = new Person("Mary", 20);
etc
Here joe and mary and whatever other objects we create are elements of the Person set. 

And just like a string, our Person set can contain an infinite amount of elements, as there is no limit to the number of objects we can instantiate. 

We can also use the set notation to represent the Person set.

{x ∈ Person: x instanceOf Person } 

Since TypeScript has a structural type system instead of a nominal type system, we can also specify the Person set as follows:

{x ∈ Person: x.name exist with type string && x.age exist with type number } 

Using the set theory perspective, we can also view compile-time error due to type error in another light.

So for example if we try to assign the value true to a variable we typed as Person, as shown below:
let doe: Person = true
we get an error: 

Type 'boolean' is not assignable to type 'Person'  

This is because we have said doe is a variable that should hold a value that should only be found in the set of Person, but here we are, going against that by trying to put a value of true in it. Sure that won’t work! Values can only become elements of sets they belong to! 

With this set theory-based perspective outlined, let us now go ahead and try to see how Union and Intersection types work in TypeScript.

What is Union Type


When approaching advanced types in TypeScript, remember it is helpful to think of these advanced types as mechanisms for creating new types from existing types.

In the case of Union types, we have the facility to create entirely new types by applying the union operation from set theory to types.

With Union types, we get a mechanism that allows the modeling of situations where one needs to work on values that could be from a known finite set of types. That is the basic idea behind union types.

Diagrammatically this can be illustrated as follows:




A can be a set that represents a basket of fruits, while B represents a basket of candies. The type A U B will therefore be a set that contains either a fruit or a candy. A U B. can be aliased as Office Treats if you may. This union operation means if you have a candy, it can go into the A U B set. So also, if you have a fruit, it can go into A U B, but wine, or beer, on the other hand, won’t be an element that can belong to the set A U B.

In TypeScript, union Types are created with the vertical bar: |. So for example a union type based on union-ing a string and number will be created by writing string | number.

For example to define a double function that takes a union of string and number, we have:

function double(input: number | string) {
  if (typeof input === "number") {
    return input * 2
  } else {
    return `${input} ${input}`
  }
}

console.log(double(3)) // prints 6
console.log(double("hoi")) // prints “hoi hoi”

Flow-Sensitive Typing, Type Narrowing, and Type Guards. 

Three TypeScript features that compliment Union types, and is worth mentioning right away are Flow-Sensitive Typing, Type Narrowing, and Type Guards. Understanding these features will go a long way in appreciating how to put union types to use. 

Flow-sensitive typing is a feature of a type system where the type of an expression is determined based on its position in the control flow; control flow being programming language constructs like: if statement, while statement, etc.

Type Narrowing is a process of narrowing a value to a more specific type. Type narrowing depends on the results of applying constructs called Type Guards

In the double function example above, we see both flow-sensitive typing and type narrowing at play.

Due to flow-sensitive typing, the compiler knows, that when the if-statement is true, then within the if branch, the input value has to be of type number: hence the type is narrowed from number | string to number. On the other hand, when the if statement evaluates to false, input variable will be narrowed to string.

The process of type narrowing depends on type guards, which can be seen as predicates that help in determining if a value can be narrowed to a particular type. They are used as the predicate in the control flow that determines type narrowing.

Examples of constructs that can be used as type guards include, but not limited to: the typeof operator, the in operator, or custom type guards defined using Type Predicates.

In the example above, the typeof operator is used as a type guard. The in operator can also be used, in that case, the input type may not be a primitive type. 

Usage of in operator may look like this:  

type Stack = {
  id: number,
  items: string[]
}

type Sound = {
  bass: number
  tremble: number
  volume: number
}

function double(input: Stack | Sound) {
  if ("items" in input) {
    return {
      id: input.id,
      items: input.items.concat(input.items)
    }
  } else {
    return {
      bass: input.bass,
      tremble: input.bass,
      volume: input.volume * 2
    }
  }
}

Here we define two custom types: Sound and Stack, which then allow the use of the in operator as a type guard.

Custom type guards can also be used. This involves using Type Predicates. This will look like the following:

function double(input: Stack | Sound) {
  if (isStack(input)) {
    return {
      id: input.id,
      items: input.items.concat(input.items)
    }
  } else {
    return {
      bass: input.bass,
      tremble: input.bass,
      volume: input.volume * 2
    }
  }
}

// this is the type predicate
function isStack(input: any): input is Stack {
  return "items" in input
}

The type predicate above is the isStack function:

function isStack(input: any): input is Stack {
  return "items" in input
}

It is used as a predicate to determine if the type of the input value is of a particular type. Notice the return type: input is Stack, this means if the function returns true, then the type of the input can be narrowed to Stack.

This completes the quick introduction of union types, let's take a look at Intersection types next.

What is Intersection Type


As initially mentioned, it is helpful to think of advanced TypeScript types as mechanisms for creating new types from existing types.

In the case of Intersection types, the idea is to be able to create a new type based on the intersection of two or more types. Just as Union types allow the creation of new types by applying the union operation from set theory, Intersection type allows the creation of new types based on the intersection operation from set theory.

It is a mechanism that allows modelling situations where one needs to work on values that simultaneously belong to two or more types.

Diagrammatically this can be illustrated as follows:



To illustrate, one can imagine A to be a set of fruits: apples, oranges etc, while B can be imagined to be a set of drinks: wine, sparkling water etc. 

A set C which is an intersection of both set A and set C, will therefore be a set that contains both properties of being a fruit and a drink: ie Fruit Juices.

This means any element of an intersection type will have to have properties that belong to the individual types that are involved in the intersection operation that created it.

In TypeScript, an intersection type is created using the ampersand (&)

To further illustrates, given types that defines a developer and system admin as follows:

type DevRole = {
  languages: string[],
  testing: string[]
}

type SysAdminRole = {
  oses: string[],
  automation: string[]
}

Then we can construct a DevOps type from these two types as follows: 

type DevOps = DevRole & SysAdminRole

Then a value that can be assign the DevOps type can be created as follows:

let andy: DevOps = {
  languages: ["java", "JavaScript"],
  testing: ["unit", "integration", "functional"],
  oses: ["linux", "solaris", "windows"],
  automation: ["salt", "ansible"]
}

Here a type DevOps, is defined to be an intersection of DevRole and SysAdminRole. Hence to create a value of DevOps, an element that can be a member of this set DevOps, such a value must contain properties from both DevRole and SysAdminRole.

A common confusion people usually have about union Types and Intersection types is the fact that the naming may appear counter-intuitive and that intersection types should actually be referred to as union types.

The reasoning usually goes: If a value of an intersection type C formed from two types A and B involves having all the properties from A and B: that is, formed as a result of the union of all the properties of A and B, why is it then called intersection type and not union type?.

For instance, in the example above, a value of type DevOps, will have all the properties from both DevRole and SysAdminRole, which can rightly be seen as union-ing all the properties of DevRole and SysAdminRole. Why then is it called intersection type?

The key thing to remember is that the set operation either Union or Intersection is applied on the set/types, and not on the elements that belong to the set, or values that belong to the type.

A Union type is not formed by union-ing all the properties of two (or multiple) values. A union type is formed by constructing a new type that is a union of existing types. Doing this means the new type can contain any value as long as that value belongs to one or more of its constituent types. The key thing is that the union operation is performed on the types. 

So also with intersection types. We have a type of DevRole, and another type of SysAdminRole. Then seeing these types as sets, we create another type/set that is an intersection of these two types. Although the repercussion of doing this is that the values that belong to this new type/set will have to have all the properties of both Developers and System Admins, the operation itself, the intersection, happens on the type level hence why it is called an Intersection Type.



This post is part of a series. See Introduction to Advance Types in TypeScript for more posts in the series.

No comments:

Post a Comment