petrhurtak.com site logopetrhurtak.com

Union types

Published
Updated:

Discriminate union types (also called tagged unions or algebraic data types) are one of my favorite features of any typed language.

They can be used to cleanly model and describe state transitions in your business logic and your UI. They are worth knowing about because patterns emerging from these can also be used in dynamic languages without the need for a type system.

HTTP request example

Let's take a simple example of making an HTTP request and displaying the response in the UI. With these async operations you also need to handle the loading and error case and display it to the user.

"Traditional" approach

// Initial state
let apiData = null;

// User initiates API request, data is loading
apiData = {
  error: false,
  loading: true,
  data: null
};

try {
  const requestData = await getApiData();

  // Data is fetched
  apiData = {
    error: false,
    loading: false,
    data: requestData
  };
} catch (error) {
  // Error with the request
  apiData = {
    error: error,
    loading: false,
    data: null
  };
}

// Somewhere in your UI code
if (!apiData) {
  return <p>Initial state</p>;
} else if (apiData.loading) {
  return <p>Loading</p>;
} else if (apiData.error) {
  return <p>Error</p>;
}

return <p>Api data: {apiData.data}</p>;

This is an approach that can be often seen. While it might be ok when the structure is small and local, problems will start surfacing when there will be more state transitions or when the data structure will be used in more places.

Problems

  • Instead of a clear indication of which type of state we are in, there are a bunch of boolean flags or empty/non-empty data values and we need to decide which has higher priority and what combination of these fields results in the corresponding state type.
  • The data structure can end up in states that do not make sense, like loading: true and error: true at the same time.
  • Extending the structure, let's say by adding RETRYING or CANCELLED request states, only magnifies the problem.

Discriminated union types

Let's see how we would model this problem when using union types. The best part is you do not even need typed language to take advantage of this approach.

// Initial state
let apiData = {
  type: "INITIAL"
};

// User initiates API request, data is loading
apiData = {
  type: "LOADING"
};

try {
  const requestData = await getApiData();

  // Data is fetched
  apiData = {
    type: "FINISHED",
    data: requestData
  };
} catch (error) {
  // Error with the request
  apiData = {
    type: "ERROR",
    error: error
  };
}

// Somewhere in your UI code
switch (apiData.type) {
  case "INITIAL":
    return <p>Initial state</p>;
  case "LOADING":
    return <p>Loading</p>;
  case "ERROR":
    return <p>Error</p>;
  case "FINISHED":
    return <p>Api data: {apiData.data}</p>;
  default:
    throw new Error(`Unknown apiData.type "${apiData.type}"`);
}

This is much better now because we have a clear indicator of what state transition we are in, and we do not need to check boolean flags that can be mutually exclusive. Also the current state we are in only has data relevant to it, so for example, when we are in the loading state there is no empty data field and false in the error field.

TypeScript

If you have typed language that supports union types you can take advantage of the type system to enforce these rules during compile time. Here is how it would look like in TypeScript.

type HttpData<T> =
  | { type: "INITIAL" }
  | { type: "LOADING" }
  | { type: "ERROR"; error: any }
  | { type: "FINISHED"; data: T };

This uses another interesting feature of TypeScript that is literal types, meaning you can have a type of exact values, like "INITIAL", instead of super-set like string.

type HttpData<T> =
  | { type: "INITIAL" }
  | { type: "LOADING" }
  | { type: "ERROR"; error: any }
  | { type: "FINISHED"; data: T };

const apiData: HttpData<string> = {
  type: "FINISHED",
  data: "api response data"
};

// Somewhere in your UI code
switch (apiData.type) {
  case "INITIAL":
    return <p>Initial state</p>;
  case "LOADING":
    return <p>Loading</p>;
  case "ERROR":
    return <p>Error</p>;
  case "FINISHED":
    return <p>Api data: {apiData.data}</p>;
  default:
    throw new Error(`Unknown apiData.type "${apiData.type}"`);
}