Prefer types that always represent valid states
A key to effective type design is crafting types that can only represent a valid state. This rule walks through a few examples of how this can go wrong and shows you how to fix them.
As an example, suppose you’re building a web application that lets you select a page, loads the con‐ tent of that page, and then displays it. You might write the state like this:
interface State {
pageText: string
isLoading: boolean
error?: string
}
When you write your code to render the page, you need to consider all of these fields:
function renderPage(state: State) {
if (state.error) {
return `Error! Unable to load ${currentPage}: ${state.error}`
} else if (state.isLoading) {
return `Loading ${currentPage}...`
}
return `<h1>${currentPage}</h1>\n${state.pageText}`
}
Is this right, though? What if isLoading
and error
are both set? What would that mean? Is it better to display the loading message or the error message? It’s hard to say! There’s not enough information available.
Or what if you’re writing a changePage
function? Here’s an attempt:
async function changePage(state: State, newPage: string) {
state.isLoading = true
try {
const response = await fetch(getUrlForPage(newPage))
if (!response.ok) {
throw new Error(`Unable to load ${newPage}: ${response.statusText}`)
}
const text = await response.text()
state.isLoading = false
state.pageText = text
} catch (e) {
state.error = '' + e
}
}
There are many problems with this! Here are a few:
- We forgot to set
state.isLoading
tofalse
in the error case. - We didn’t clear out
state.error
, so if the previous request failed, then you’ll keep seeing that error message instead of a loading message. - If the user changes pages again while the page is loading, who knows what will happen. They might see a new page and then an error, or the first page and not the second depending on the order in which the responses come back.
The problem is that the state includes both too little information (which request failed? which is loading?) and too much: the State
type allows both isLoading
and error
to be set, even though this represents an invalid state. This makes both render()
and changePage()
impossible to implement well.
Here’s a better way to represent the application state:
interface RequestPending {
state: 'pending'
}
interface RequestError {
state: 'error'
error: string
}
interface RequestSuccess {
state: 'ok'
pageText: string
}
type RequestState = RequestPending | RequestError | RequestSuccess
interface State {
currentPage: string
requests: { [page: string]: RequestState }
}
This uses a tagged union (also known as a “discriminated union”) to explicitly model the different states that a network request can be in. This version of the state is three to four times longer, but it has the enormous advantage of not admitting invalid states. The current page is modeled explicitly, as is the state of every request that you issue. As a result, the renderPage
and changePage
functions are easy to implement:
function renderPage(state: State) {
const { currentPage } = state
const requestState = state.requests[currentPage]
switch (requestState.state) {
case 'pending':
return `Loading ${currentPage}...`
case 'error':
return `Error! Unable to load ${currentPage}: ${requestState.error}`
case 'ok':
return `<h1>${currentPage}</h1>\n${requestState.pageText}`
}
}
async function changePage(state: State, newPage: string) {
state.requests[newPage] = { state: 'pending' }
state.currentPage = newPage
try {
const response = await fetch(getUrlForPage(newPage))
if (!response.ok) {
throw new Error(`Unable to load ${newPage}: ${response.statusText}`)
}
const pageText = await response.text()
state.requests[newPage] = { state: 'ok', pageText }
} catch (e) {
state.requests[newPage] = { state: 'error', error: '' + e }
}
}
The ambiguity from the first implementation is entirely gone: it’s clear what the cur‐ rent page is, and every request is in exactly one state. If the user changes the page after a request has been issued, that’s no problem either. The old request still com‐ pletes, but it doesn’t affect the UI.
Oftentimes this rule pairs with the ideal of having as little mutable state as possible and preferring to derive state based on a small source of truth which is always valid.
For example, let’s say you have a product resource:
class Product {
isInStock: boolean
quantityAvailable: number
}
Product
has a few problems here:
isInStock
can be false withquantityAvailable > 0
which doesn’t make any senseisInStock
can be true withquantityAvailable === 0
which doesn’t make any sense
The problem comes from Product.isInStock
and Product.quantityAvailable
both representing different aspects of the same underling data: in this case, how much of a product is currently available.
A better solution would be to only store the minimal state necessary to model the Product
’s valid states, and then derive any additional fields based on the model’s minimal, valid state:
class Product {
quantityAvailable: number
get isInStock() {
// Derived based on `quantityAvailable` which guarantees that the product's
// state is always valid.
return this.quantityAvailable > 0
}
}
Caveats
When working with external APIs and data sources, it’s not always possible to work with types which only represent valid state. So this rule should ignore any data coming from external dependencies and focus instead on types used internally within this project.
Key Takeaways
Types that represent both valid and invalid states are likely to lead to confusing and error-prone code.
Prefer types that only represent valid states. Even if they are longer or harder to express, they will save you time and pain in the end.
If a field is useful, but adding it to a type could result the type representing invalid states, then consider whether that field can be derived from a minimal set of state that is always valid.
Metadata
Key | Value |
---|---|
name | prefer-types-always-valid-states |
level | error |
scope | file |
fixable | false |
cacheable | true |
tags | [ best practices ] |
exclude | [ **/*\.test\.{js,ts,jsx,tsx,cjs,mjs} , **/*\.{js,cjs,mjs,json} ] |
resources | [ https://effectivetypescript.com ] |
gritqlNumLinesContext | 3 |
gritql | true |