Reducing Typescript and Redux Boilerplate with Partial Actions
- 9/4/2017
- ·
- #typescript
- #redux
- #howto
Static types can help reduce defects in evolving codebases, but their benefits need to balance out against the inevitable boilerplate. We’ve previously used TypeScript to secure the Redux dispatch flow, but the repetition involved didn’t feel good at the time and hasn’t gotten any better with age.
The problem is that we want to use type-narrowing to reveal logical issues, while still being able to describe like actions in generic terms. Consider a family of actions representing the states of an API request:
// actions.ts
type ListRequest = {
count?: number
page?: number
}
type Books = {
resource: string,
items: Array<Book>
}
export type Action =
{ type: 'LIST_BOOKS_REQUEST', request: ListRequest }
| { type: 'LIST_BOOKS_RESPONSE', request: ListRequest, response: Books }
| { type: 'LIST_BOOKS_ERROR', request: ListRequest, error: string }
We should be able to count on strong, static guarantees anywhere we dispatch or
consume the LIST_BOOKS
actions.
// reducers/books.ts
import { Action } from '../actions'
type Books = {
isLoading: boolean,
error: null | string,
books: Array<Book>,
}
const initialState = {
books: [],
isLoading: false,
error: null,
}
function books (state: Books = initialState, action: Action): Books {
switch (action.type) {
case 'LIST_BOOKS_REQUEST':
return { ...state, isLoading: true }
case 'LIST_BOOKS_ERROR':
return { ...state, isLoading: false, error: action.error }
case 'LIST_BOOKS_RESPONSE':
return {
error: null,
isLoading: false,
books: state.books.concat(action.response.items)
}
default:
return state
}
}
We would see a compiler error if books
tried to access an unknown field.
Good. But in order to create actions from the LIST_BOOKS
family, we need to
explicitly set both their types and their contents. In the previous attempt, we
needed a fairly hefty definition to be able to represent asynchronous
actions in a reasonably generic way:
const _listBooks: ApiAction<ListRequest, Books> = {
request: (request) =>
({ type: 'LIST_BOOKS_REQUEST', request }),
success: (response, request) =>
({ type: 'LIST_BOOKS_RESPONSE', request, response }),
error: (error, request) =>
({ type: 'LIST_BOOKS_ERROR', request, error }),
}
function createApiAction<Q, S>(a: ApiAction<Q, S>, fetch: (q: Q) => Promise<S>) {
return (req?: Q) => {
// Make API request and handle result, dispatching
// appropriate actions along the way
}
}
export const listBooks = createApiAction(_listBooks, opts => api.books.list(opts))
The ApiAction
type enables a generic interface, but the type conversion
underpinning it in _listBooks
is a compromise. Since we can’t define type
strings dynamically, every new API action family we define will need its own
implementation. The common interface is better than working with bespoke
actions. But it’s hardly a joy.
Partial Actions
The React ecosystem has trained us (quite rightly) to prize obviousness. Using discrete actions to represent changes within the application makes it clear–sometimes painfully clear–to follow what’s going on. We’re about to break all that.
The little bargain we’re about to strike will but save us a heap of boilerplate, but at the cost of discreet actions. We’ll lose a degree of obviousness in reducer logic and middleware. We’ll keep type-safety. Goethe can opine as he will.
Ready?
Returning to our first example, let’s replace the three actions in the
LIST_BOOKS
family with a single type full of optional properties:
export type Action = {
type: 'LIST_BOOKS',
request?: ListRequest,
response?: Books,
error?: string,
}
We can think of this as a “partial” action. Like other forms of deferred state,
the LIST_BOOKS
action will be filled out with additional content as the
request proceeds.
Remember that uncomfortable thing we were about to do? This is it. But using this pattern, we can now extract a generic type for the possible values of an API (or really any asynchronous) request:
type ApiAction<Q, S> = {
request?: Q,
response?: S,
error?: string,
}
export type Action =
({ type: 'LIST_BOOKS' } & APIAction<ListRequest, Books>)
References to LIST_BOOKS
elsewhere will still be narrowed to the correct
type, but we can no longer refer to _ERROR
and _SUCCESS
actions
representing the possible outcomes. Instead, we’ll need to derive state from
the action’s content.
function books (prevState: Books = initialState, action: Action): Books {
if (action.type === 'LIST_BOOKS') {
const state = Object.assign({}, initialState)
if (action.response) {
state.books = state.books.concat(action.response.items)
} else if (action.error) {
state.error = action.error
} else {
state.isLoading = true
}
return state
}
return prevState
}
Conclusion
The upside? Reusing the type string across multiple states can save quite a bit of ceremony–in this example replacing the actions in a traditional TypeScript/redux implementation, boilerplate dropped by over 50%.
But whether it’s worth breaking the boundary between actions’ responsibilities will depend on the project and team. Partial actions are just a compromise between language and convention that may make sense in one context but not in another.
And as ever, let me know how it goes!