March 22nd, 2020

🤵 Tailor-made union types

Article cover photo
Photo by Alvin Mahmudov on Unsplash

When you have a static type system introduced into your app's codebase, the next nice thing that you can do is to refine some types so they represent only meaningful states of the application.

Let's consider type which describes state of some fetch response:

type Response = {
  loading: boolean;
  error: boolean;
  payload?: object;
};

So it has 3 states that make sense:

  • { loading: true, error: false } - request has been sent but no response has been received yet
  • { loading: false, payload: {...} } - we've got successful response with some data
  • { loading: false, error: true } - some error happened, for instance there were some problems with network connection

But there is more. This type also enables the following states:

  • { loading: true, error: true } - we are waiting, but something bad already happened? So what are we waiting for?
  • { loading: false, error: false } - we've got nothing, but also nothing happens that would change this...
  • { loading: false, error: true, payload: {...} } - we've got error, but what does the payload represent?
  • { loading: true, error: false, payload: {...} } - we are still waiting but we've got payload already? Again, what are we waiting for?
  • { loading: true, error: true, payload: {...} } - combo!

Of course, these ones are useless and it makes no sense to have them covered by runtime. But anyhow, can we do something about it on a static type level? Let's start with these boolean values first...

When you have type that consists of multiple booleans that are somehow related, it's worth considering to turn them into enum or union type:

  • { loading: true, error: false }'LOADING'
  • { loading: false, error: false }'SUCCESS'
  • { loading: false, error: true }'ERROR'
  • { loading: true, error: true } → should never happen!

And from the above can conclude the following type:

type ResponseStatus = 'LOADING' | 'SUCCESS' | 'ERROR';

and then let's make use of it:

type Response = {
  status: ResponseStatus;
  payload?: object;
}

This is cool because we got rid of { loading: true, error: true } state that should never happen!

But hold on... We've still got some misleading states that make no sense:

  • { status: 'LOADING', payload: {...} }
  • { status: 'ERROR', payload: {...} }

We can do better by remembering that union types aren't reserved to primitive values only! We can model our type so payload property is tied only to the 'SUCCESS' status:

type SuccessfulResponse = {
  status: 'SUCCESS';
  payload: object;
}

and for the rest statuses:

type ResponseInProgress = {
  status: 'LOADING';
};

type ErrorResponse = {
  status: 'ERROR';
};

Having all of these we can just combine them into one union type:

type Response = ResponseInProgress | SuccessfulResponse | ErrorResponse;

And that's it! Problematic states are no longer representable. What's more, TypeScript compiler and your IDE will become smart at deducing what's possible when you will branch your code depending on the status property:

const response: Response = someFunctionThatReturnsResponse();

if (response.status === 'SUCCESS') {
  response.payload // compiler know that response has payload field
} else if (response.status === 'LOADING') {
  response.payload // we don't have that here - compiler error
} else {
  // Compiler knows that "response.status" must be 'ERROR' because there is no other option!
}

Unfortunately, there is one pitfall in this feature. In TypeScript it wont work if the value looses context of the object that it belongs to. For instance if you use destructuring like this:

const response: Response = someFunctionThatReturnsResponse();
const { status } = response;

if (status === 'SUCCESS') {
  response.payload // Error! Compiler is not smart enough to deduce that this induces "payload" property existence
}

But still, I think types designed that way could be really helpful!

Summary

  • Consider converting multiple booleans into enum or union type and just omit problematic states.
  • Union types work for complex types like objects too and this enables you to control properties presence and type in a way that it is depending on some other property.

I hope it inspires you to design nicely typed APIs, so... happy typing! 😊