Modeling State with TypeScript

Modeling State with TypeScript

Managing state on the frontend can often be tricky. Beyond perennial issues around keeping the view and model in sync (which modern frameworks can help with greatly) our model can simply have the wrong state in it. However, using TypeScript, we can define our types in such a way as to make those bad states unrepresentable. Let’s look at an example.

Fetching a User

Say we’re fetching a resource, such as a user, from our API and we represent our initial user state as follows:

const initialUserState = {
  isPending: false,
  errorMessage: null,
  user: null
};

Once our request is initiated, isPending is set to true so we can, for example, show a loading spinner in the UI. If there’s an error fetching the user, we fill in the errorMessage with the appropriate string and display the error. Otherwise, we set user with user data once it has been retrieved successfully.

Since initialUserState is just an object, it can have any property with any value of any type. This extreme flexibility can actually open us up to bugs if we accidentally set our state to something that was not intended, whether it be due to a typo, logic error, accidental type conversion, or even a change someone else made without full knowledge of the codebase.

Luckily, TypeScript can help avoid these scenarios by allowing for defining types more precisely and providing compile-time checks to ensure type safety.

Interfaces

In TypeScript, we can define interfaces for our User model and our UserState object to give some structure to our data like so:

interface User {
  id: number;
  name: string;
}

interface UserState {
  isPending: boolean;
  errorMessage: string | null;
  user: User | null;
}

The User interface requires an id property of type number and a name property of type string.

This UserState interface requires that our value have an isPending property that is a boolean, an errorMessage property that is either a string or null, and a user property that is either a User or null.

This is all well and good, but what if, due to some error in our code, our state accidentally ends up looking like this?

{
  isPending: true,
  errorMessage: "An error occurred",
  user: null
}

The request is still pending, yet there is an error message. What should the UI look like in that case?

Or this:

{
  isPending: false,
  errorMessage: "An error occurred",
  user: {
    id: 123,
    name: "Joe Schmo"
  }
}

There was an error but a user was still retrieved. Did the request succeed or fail…?

Want to work on projects with a social and civic impact? Learn what it’s like to work at Azavea.

Visit our career site

Union Types

To address this, we can define UserState in a different way using a union type to better represent the possible states for our user data:

interface UserResourceInitial {
  isPending: false;
}
interface UserResourcePending {
  isPending: true;
}
interface UserResourceSuccess {
  user: User;
}
interface UserResourceFailure {
  errorMessage: string;
}

type UserResource = UserResourceInitial | UserResourcePending | UserResourceSuccess | UserResourceFailure;

A union type can be one of several types. The pipe (“|”) separates the possible forms that a UserResource can take, i.e. the possible types it can be. And it can only be one of these at a time. In the case of a UserResource, it can either have an isPending property that is a boolean, a user property that is a User, or an errorMessage property that is a string but it must be one and only one of those. Problem states such as pending resources that have also failed or resources that somehow both succeeded and failed are unrepresentable as UserResources!

const pendingAndFailed: UserResource {  // won't compile!
  isPending: true,
  errorMessage: "An error occurred",
  user: null
}
const succeededAndFailed: UserResource = {  // won't compile!
  isPending: false,
  errorMessage: "An error occurred",
  user: {
    id: 123,
    name: "Joe Schmo"
  }
}

Whereas valid values would look like this:

const initial: UserResource {
  isPending: false
}
const pending: UserResource {
  isPending: true
}
const success: UserResource {
  user: {
    id: 123,
    name: "Joe Schmo"
  }
}
const failure: UserResource {
  errorMessage: "An error occurred"
}

Generics

We can take this a step further and make a generic Resource type that takes a type T as a parameter:

interface ResourceInitial {
  isPending: false;
}
interface ResourcePending {
  isPending: true;
}
interface ResourceSuccess<T> {
  resource: T;
}
interface ResourceFailure {
  errorMessage: string;
}

type Resource<T> = ResourceInitial | ResourcePending | ResourceSuccess<T> | ResourceFailure;

Now we have a reusable type that works for any type of resource whose state always makes sense.

Type Narrowing

If we wanted to write a function to output a message for our user resource, we could implement it like so:

function userResourceMessage(userResource: Resource<User>): string {
  return "isPending" in userResource && userResource.isPending === false
    ? "Waiting to fetch user resource"
    : "isPending" in userResource && userResource.isPending === true
    ? "Fetching user resource..."
    : "resource" in userResource
    ? `User's name is ${userResource.resource.name}`
    : "errorMessage" in userResource
    ? `Error fetching user: ${userResource.errorMessage}`
    : assertNever(userResource);
}

Note that the TypeScript compiler is smart enough to narrow the type within each expression based on which properties are on userResource and consequently which properties are available for display.

The assertNever function is a little trick to get exhaustiveness checking:

function assertNever(x: never): never {
  throw new Error(`Unexpected: ${x}`);
}

In TypeScript, never represents values that should never occur. By returning never as a fallback if nothing else matches, the compiler will alert us if never does in fact match as that means we failed to account for a valid case. This means that if we were to add another possible state to Resource then we would have to account for it in this function, otherwise our code will not compile!

State Transitions

At this point, we are already getting a lot of benefit from modeling our state more precisely through types. Our data must be one of four possible types and this is enforced at compile time.

However, we are not enforcing the transitions between the various states. To give an example, going from a ResourceInitial directly to a ResourceFailure without ever having a ResourcePending doesn’t make sense. To enforce state transitions, we can make our type definition even more precise by using conditional types.

Conditional Types

Conditional types allow us to define a type that is conditioned on (i.e. dependent on) the type you pass into it. We can think of it as a kind of type factory — a type that takes in one or more types and spits out a new type:

type NextResource<T, R extends ResourceInitial | ResourcePending | ResourceSuccess<T> | ResourceFailure | undefined = undefined> =
    R extends undefined ? ResourceInitial :
    R extends ResourceInitial ? ResourcePending :
    R extends ResourcePending ? ResourceSuccess<T> | ResourceFailure :
    never;

Here we define a new NextResource type whose type depends on the types being passed into it. The first type parameter T is the type of resource we’re working with (in our case, User). The second type parameter R represents the current resource state; you pass the current state in and get the new state out. The extends keyword indicates that R is assignable to ResourceInitial | ResourcePending | ResourceSuccess<T> | ResourceFailure | undefined.

In the type definition, ternary syntax is used to represent the conditional logic involved in the type resolution. If R is undefined (or missing entirely, since the default is undefined), the type is ResourceInitial. If R is a ResourceInitial, the type is ResourcePending, etc.

Let’s look at some examples of how NextResource can be used:

const initial: NextResource<User> = {
  isPending: false
};
const pending: NextResource<User, typeof initial> = {
  isPending: true
};
const success: NextResource<User, typeof pending> = {
  resource: {
    id: 123,
    name: "Joe Schmo"
  }
};
const failure: NextResource<User, typeof pending> = {
  errorMessage: "error"
};

By representing our state as a NextResource, we can require our data be of the correct type to transition to the next state!

Conclusion

TypeScript provides us with a powerful type system that can be used to encode application logic in our types in a precise way. The more complexity we can push to the type level, the more the compiler can constrain possible values to make bad states unrepresentable and help prevent bugs!

If you would like to experiment with any of the examples shown above, they’re available on TypeScript Playground.