This content originally appeared on Level Up Coding - Medium and was authored by Joseph Michael Sample
Getting the most out of Typescript’s most undervalued feature
If you’re not familiar with it, the Union type is the basic Typescript tool we use to describe a value that is “one of several different types”.
type CarOrBus = Bus | Car
This tool, it turns out, has some especially useful applications with React state. Knowing how and when to use Union types to describe your component state will enable you to write less code, with fewer errors. So what do I mean exactly?
Let’s start by looking at some example source code for a search component in a movie app we are building. At any given point in time this component should be either:
- ⏳ Loading- Showing loading indicator
- ✅ Done- Showing search results
- 🚨 Error- Showing search error
Let’s take a look at the code. 👇
What happens when a successful search result is returned after the user has previously encountered an error?
{searchResults && <MovieList movies={searchResults} />}
{isLoading && <LoadingIndicator />}
{error && <ErrorMessageUI message={error.message} />}
There are a number of bad view states our app can get into here. You can have a loading message showing above an error message, you could have search results appearing above an error message. You could technically be showing all three of these elements at the same time. How can we ensure that we only show one of these view states at a time?
Well, one approach is to change our rendering logic and enforce that only one of these elements is rendered at a time.
let content: ReactElement = null
if (errorMessage) {
content = <MovieList movies={searchResults} />
} else if (isLoading) {
content = <LoadingIndicator />
} else if (searchResults != null) {
content = <ErrorMessageUI message={error.message} onClose={() => setError(undefined)} />
}
return (
<div>
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
{content}
</div>
)
While adding this rendering logic does guarantee that we only ever show one of these elements at a time, it has problems of its own. This logic gives us a strict hierarchy of our state variables that we will have to keep in mind when updating our state setting logic in our useEffect hook. We’re essentially splitting up the logic for “what gets displayed when” into two separate places, and believe me- this gets hairy quick. Additionally, we’ve lost a lot of readability.
So what’s the alternative? What if we kept the render logic as simple as possible and instead we will carefully update our useEffect hook to ensure that the ⏳ Loading, ✅ Done, and 🚨 Error view states are mutually exclusive.
useEffect(() => {
// 1. Loading state
setIsLoading(true)
setError(undefined)
setSearchResults(undefined)
searchForMovies(searchTerm).then(data => {
// Success results state
setSearchResults(data)
setError(undefined)
setIsLoading(false)
})
.catch((error: Error) => {
// Error state
setError(error)
setSearchResults(undefined)
setIsLoading(false)
})
}, [searchTerm])
There we go. Now we are explicitly setting all those state values that affect our view state at the same time. By looking at the code here we can manually verify that all these combinations of state values will give us valid view state.
But that’s a lot of boilerplate for a bunch of state values that will always change at the same time. It’s gonna be a pain adding this everywhere we have a network request in our app.
Well, if they all change at the same time, why don’t we put them in a single piece of state? 💡
type ViewState = {
loading: boolean,
error?: Error,
searchResults?: Movie[],
}
const [viewState, setViewState] = useState<ViewState>({ loading: true })
Here’s what that would look like all together:
Well that’s an improvement, much easier to read! Additionally, since the entirety of view state is encapsulated in this ViewState type we can omit the keys we want to “unset” which is handy.
So this is much cleaner, but we are still at the whim of our own proofreading and if we’re not careful we could still update our view state with an invalid combination of values.
setViewState({ isLoading: false, searchResults: movies })
// 👆 Bad view state, should not be loading and showing results
// at the same time
What if we could leverage the typescript compiler to add constraints to our typescript ViewState type, and prevent us from ever setting invalid view state combinations??
Enter Union types
So I mentioned earlier that Union types allow us to define a type that is “one of several different types”.
Here’s a really simple example of how we’d define the type string or undefined.
let a: string | undefined = undefined
But it’s not just for primitive types like string and number, you can create a union type from any other type:
type Foo = { a: string } | { b: string }
declare function acceptFoo(foo: Foo): void;
acceptFoo({ a: '' }) ✅
acceptFoo({ b: '' }) ✅
So how can we use union types to get more specific about our 3 distinct combinations (⏳, ✅ ,🚨) of our ViewState properties? Let’s go back to our 3 setState calls in the last example and create separate types for them. We can then combine them all with a union type.
type ViewState = (
// ⏳ 'Loading' state
{ loading: true, searchResults?: undefined, error?: undefined } |
// ✅ 'Done' state
{ loading: false, searchResults: Movie[], error?: undefined } |
// 🚨 'Error' state
{ loading: false, searchResults?: undefined, error: Error }
)
Our new ViewState definition now says that our viewState variable must match one of these specific types. Take a look at what happens now, when we attempt to call setViewState with an invalid combination of properties like we did before:
// 👇 Attempting to set bad view state 👇
setViewState({ loading: true, error: error })
// ❌ TS Error: Type 'true' is not assignable to type 'false'
Our setViewState function takes expects an argument of type ViewState- our new union type. The type of the object literal we are passing { loading: true, error: Error } does not match any of the members of the union type, so Typescript raises an error and prevents us from writing code that would get us into those bad view states!
Pretty neat- we’ve successfully fool-proofed our component state and let TypeScript catch these potential bugs instead of our users! But there’s more we can do with this.
Type narrowing 👉 👈
To illustrate this next Typescript feature, let’s introduce another product requirement. We need to pass a loadingSince property to our LoadingIndicator component can display how long the search has been loading. Here’s the updated function signature for that component:
declare function LoadingIndicator(
props: { loadingSince: Date }
): JSX.Element
So let’s add the loadingSince property to our ViewState type. But hmm- we really only need that property when we are in the ⏳ Loading state. So what if we only added that key to the ⏳ Loading state type in our union?
type ViewState = (
// ⏳ 'Loading' state
{
loading: true,
searchResults?: undefined,
error?: undefined,
loadingSince?: Date
} |
// ✅ 'Done' state
{ loading: false, searchResults: Movie[], error?: undefined } |
// 🚨 'Error' state
{ loading: false, searchResults?: undefined, error: Error }
)
Now we’re getting a typescript error again. What gives?
{viewState.loading && viewState.loadingSince != null && (
<LoadingIndicator loadingSince={viewState.loadingSince} />
👆 ❌ Property 'loadingSince' does not exist on type 'ViewState'
)}
Typescript doesn’t seem to think this property loadingSince exists on our variable viewState at all. Why?
We’ve told Typescript that the loadingSince property only exists on one of the member of our union type. Given that the typescript compiler does not know if our viewState variable matches our ⏳ Loading, ✅ Success or 🚨 Error types, Typescript can only give us access to properties that are guaranteed to exist on the type, which are those properties that exist on every type in the union.
So how do we access properties of individual members of the union type?
All we’ve got to do is tell Typescript which particular union member type (⏳, ✅, or 🚨) we’re dealing with. We can do that by writing conditional code paths (control flow) that “narrow” the type. Here’s are some simple examples to illustrate how “type narrowing” works in Typescript.
function multiplyByTwo(value: number | undefined) {
if (value != null) {
// ️️❗️ value has been narrowed to type `number` here
return value * 2.0
} else {
// ❗️ value has been narrowed to type `undefined` here
return null
}
}
function callFunctionIfPossible(func: (() => void) | undefined) {
return func && (
// ❗️ value has been narrowed to type `() => void` here
func()
)
}
And how can we narrow a list of custom types like in our example with the ViewState union?
There are a number of different ways to do this but in our case I would suggest the best option is differentiate our types members by giving them a unique identifier under a shared key. How would that look?
type ViewState = (
// ⏳ 'Loading' state
{ status: 'loading', loadingSince: Date } |
// ✅ 'Done' state
{ status: 'done', searchResults: Movie[] } |
// 🚨 'Error' state
{ status: 'error', error: Error }
)
A couple other changes things to note here:
- We took out all unnecessary keys from the type definitions of our view states. For instance, we no longer need to add error?: undefined to the ✅ ‘Done’ state. As long as we’re planning on narrowing the type before accessing keys, we don’t have to have any shared keys outside of our identifier, “status”.
- The ‘loading’ property was removed from all states as the ‘status’ field makes it redundant. This will make more sense in the next code example.
Now if we want to access to those properties we just make sure to narrow the type by checking the status field beforehand. This is also kind of nice because the status field also serves as a label for the type and makes the code more readable.
{viewState.status === 'loading' && (
❗️ viewState type narrowed to ⏳ Loading state here
<Loading loadingSince={viewState.loadingSince} />
)}
{viewState.status === 'done' && (
❗️ viewState type narrowed to ✅ Done state here
<MovieList movies={viewState.searchResults} />
)}
{viewState.status === 'error' && (
❗️ viewState type narrowed to 🚨 Error state here
<ErrorMessageUI error={viewState.error} />
)}
You might have noticed that we no longer have to check for null on viewState.searchResults and viewState.error after narrowing the type. This can save us a lot of unnecessary checks and is especially useful when we have a number of associated values on a given member of the union type! 🎉
So here’s what it looks like altogether:
There’s a lot more to explore with Typescript unions. You’ll actually notice a lot of libraries make use of union types and type narrowing including react-async and redux.
That’s all for now. Hope this helped! 👋
React state meets Typescript’s Union type was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Joseph Michael Sample
Joseph Michael Sample | Sciencx (2022-05-05T10:37:18+00:00) React state meets Typescript’s Union type. Retrieved from https://www.scien.cx/2022/05/05/react-state-meets-typescripts-union-type/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.