DEV Community

Jun Kaneko
Jun Kaneko

Posted on

Using Algebraic Data Types (ADTs) in TypeScript

Algebraic Data Types (ADTs) are concepts originally from functional programming languages, representing composite types formed by combining other types. In TypeScript, ADTs can be achieved using union types ("|" operator), intersection types ("&" operator), and discriminated unions.

type Alien = {
  type: 'unknown';
}

type Animal = {
  species: string;
  type: 'animal';
}

type Human = Omit<Animal, 'type'> & {
  name: string;
  type: 'human';
}

type User = Animal | Human;

Enter fullscreen mode Exit fullscreen mode

A discriminated union in TypeScript is a way to create a type that combines union types and has a common property that can be used to determine the specific type of the variable at runtime. In the example above, type: 'animal' or 'human' is the common property.

Algebraic Data Types (ADTs) provide several benefits:

Expressiveness and Maintainability:

ADTs allow to model complex data structures and relationships naturally by providing clear, self-documenting type definitions. They enable you to create composite types that accurately represent the domain, making the code easier to understand and reason about.

Type safety:

By using ADTs, you can explicitly define the structure of your data, making it easier for the TypeScript compiler to catch potential issues at compile time.

Pattern matching and exhaustiveness checks:

When working with ADTs, especially sum types (discriminated unions), you can leverage TypeScript's ability to perform pattern matching and exhaustiveness checks. These checks help ensure that you've handled all possible cases when working with a value, reducing the chances of bugs due to unhandled cases.

Encapsulation of logic:

ADTs can help you encapsulate the logic related to specific data structures, such as validation or transformation, making your code more modular and easier to reason about.

Utility Types

TypeScript provides several utility types to help describing the composition such like the ADTs.

type Human = Omit<Animal, 'type'> & {
  name: string;
  type: 'human';
}
Enter fullscreen mode Exit fullscreen mode
  • Omit : This is a utility type that creates a new type by removing the specified keys from the given type. In this case, it removes the type property from the Animal type.
  • &: { } : The intersection type operator combines two or more types into a single type that has all the properties of the original types.

By combining the types with the intersection type operator, the Human type ends up having the properties species and name, as well as the discriminant type with the value 'human'. The Human type is essentially an extension of the Animal type with some modifications.

Finally, User is a union type that can represent any of the Animal, Human, or Adult types.

Difference between extends and & intersection

It is possible to write the similar data structure by using interface with extends.

interface Animal {
  species: string;
  type: 'animal';
}

interface Human extends Omit<Animal, 'type'> {
  name: string;
  type: 'human';
}
Enter fullscreen mode Exit fullscreen mode

The interface keyword is primarily used for defining object types, while the type keyword is used for defining any type, including object types, union types, and intersection types.

One key difference between using extends and & is that extends creates a new type that has an inheritance relationship with the parent type, while & creates a new type that is a combination of two or more existing types without any inheritance relationship.

It is a personal preference, but type could be more flexible when you have to describe a lot of intersections.

How to predict unknown data

Suppose we have the following un-typed data, how can we let TypeScript compile to allocate them to the proper types?

const ghost: unknown = null;
const alien: unknown = {}

const sheep: unknown = {
  species: 'sheep',
}

const jun: unknown = {
  name: 'Jun',
  species: 'human',
}

const anomany: unknown = {
  name: 'anomany',
}
Enter fullscreen mode Exit fullscreen mode

User-defined type guards

User-defined type guards are a feature in TypeScript that allows you to create custom type-checking functions that can narrow down the type of a value within a specific scope.

  • Takes a value as a parameter, which is usually of a more general type, such as unknown or a union type.
  • Returns a boolean value, which indicates whether the value matches the expected type.
  • Uses a special type predicate in the return type annotation, in the format "value is SpecificType". This tells TypeScript that the function can narrow down the type of the value to SpecificType when it returns true. In the following example, "someone is Animal", "someone is Human" and "data is { [key: string]: unknown }" are the type predicates
function hasProperty(data: unknown): data is { [key: string]: unknown } {
  return data != null;
}

function isAnimal(someone: unknown): someone is Animal {
  return hasProperty(someone) && typeof someone.species === 'string'
}

function isHuman(someone: unknown): someone is Human {
  return hasProperty(someone)
  && typeof someone.name === 'string'
  && someone.species === 'human';
}
Enter fullscreen mode Exit fullscreen mode

hasProperty is a type guard function that checks if data is an object with string keys and unknown values.

isAnimal and isHuman are type guard functions for the Animal and Human types, respectively. These functions check whether an unknown entity has the required properties and correct types to be an Animal or Human.

function categorise(item: unknown): User | Alien {
  if (isHuman(item)) {
    return { ...item, type: 'human' } as Human;
  }
  if (isAnimal(item)) {
    return { ...item, type: 'animal' } as Animal;
  }
  return { type: 'unknown' } as Alien;
}
Enter fullscreen mode Exit fullscreen mode

The categorise function takes an unknown item and returns a User (either Animal or Human) or an Alien. It uses the type guard functions isHuman and isAnimal to check the type of the item and adds the type property accordingly.

function parseItems(items: unknown[]): User[] {
  return items.flatMap((item) => {
    const categorised = categorise(item)
    return categorised.type === 'unknown' ? [] : [categorised];
  })
}
Enter fullscreen mode Exit fullscreen mode

The parseItems function takes an array of unknown items and returns an array of User entities. It categorises each item using the categorise function and filters out any items with the type property set to 'unknown'.

Render data according to the type

Once each data is assigned to a specific type, you can safely consume the data. For example, if you want to render them differently according to the type:

function renderHuman(someone: Human) {
  return `${someone.name} is a human`;
}

function renderAnimal(someone: Animal) {
  return `It is a ${someone.species}`;
}

function renderItems(items: User[]) {
  return items.map((item) => {
    switch (item.type) {
      case 'human':
        return renderHuman(item);
      case 'animal':
        return renderAnimal(item);
      default:
        return 'unknown';
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Your editor can show code suggestions and errors benefitting the type safety.

Image description

And the previous data will render the expected output.

const list = parseItems([ghost, alien, anomany, jun, sheep]);
console.log(renderItems(list))

[ 'Jun is a human', 'It is a sheep' ]
Enter fullscreen mode Exit fullscreen mode

Top comments (0)