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…?
[adinserter block=”1″]
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 UserResource
s!
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.