Union types
Discriminate union types (also called tagged unions or algebraic data types) are very helpful and are in my personal top 2 of must-have advanced features of any typed language, along with non-nullable types.
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 of type system.
HTTP request example
Let's take a simple example of making API HTTP request and displaying the response in the UI. Since the request takes some time to finish, or it can fail, we will also need to handle these cases.
"Classic" approach
// Initial state
let apiData = null;
// User initiates API request, data is loading
apiData = {
error: false,
loading: true,
data: null
};
try {
const request = await fetch(apiUrl);
const requestData = await request.text();
// Data is downloaded
apiData = {
error: false,
loading: false,
data: requestData
};
} catch (error) {
// Error state
apiData = {
error: error,
loading: false,
data: null
};
}
// Somewhere in your UI code
if (!apiData) {
return <p>Initial state</p>;
}
if (apiData.loading) {
return <p>Loading</p>;
}
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 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 corresponding state type.
- Data structure can end up in states that do not make sense, like
loading: true
anderror: true
at the same time. - Extending the structure, let's say by adding
RETRYING
orCANCELLED
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 request = await fetch(apiUrl);
const requestData = await request.text();
// Data is downloaded
apiData = {
type: "FINISHED",
data: requestData
};
} catch (error) {
// Error state
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 clear indicator 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 loading state there is no empty data field and false in error field.
TypeScript
If you have typed language that supports union types you can take advantage of the type system to enforce these rules. 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}"`);
}
What we can then do is use the never type, to opt into exhaustiveness checking. Meaning you will need to handle all cases of your union type, otherwise, the compiler will complain. This is another very useful compiler check that you will appreciate as your codebase grows, and in case you ever need to extend your union type, like adding CANCELLED
state to your HTTP request type, it will force you to update all pieces of code where given union type is used.
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"
};
function never(value: never): never {
throw new Error("This part of code should never be reached. " + JSON.stringify(value));
}
// 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:
// If you do not handle all cases, TS will complain:
// Argument of type '{ type: "FINISHED"; data: string; }' is not assignable to parameter of type 'never'.
return never(apiData);
}