40
How to Use Type Guards for Type-Safe Events in TypeScript
Filtering a large set down to an interesting subset is one of the most common things we do in computing - it saves users time! In TodoMVC, for example, the set of active todos is a subset of all TODOs.
However, In TypeScript, when the subset is of a more specific type, it's not automatic that the subset is of the narrowed type. A particular example we will show in this post: when an event bus is known to have Flux Standard Actions, and we have listeners registered for certain actions, we want type-safety within those listeners! Let's see how type guards/predicates work in TypeScript in general, then apply it to the Omnibus event bus library.
Let's take an example from the TypeScript docs to get started.
In this example, to get an array of Fish
out of an array of multiple animals, we have a filter function isFish
that can create the subset.
const zoo: (Fish | Bird)[] = [...];
const fishes: Fish[] = zoo.filter(isFish) as Fish[];
See that as Fish[]
at the end? That's what we had to add to get type safety on the filtered array of fish. This explicit cast of type is necessary if the implementation of the isFish
function returns only a boolean. But there's a way to tell TypeScript that we'll only return true from isFish
for objects which are of type Fish
. The way we do that is with a "type predicate", or "user-defined typeguard" (docs). Here's how it looks with a type guard:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
const fishes: Fish[] = zoo.filter(isFish)
When the return type of isFish
is pet is Fish
, we get type-safety on the array of fish - and anywhere else we'd use this predicate! This type of implicit typing is better for reading and maintaining the code, and many functions exported by libraries will do this for you..
Now how does this apply to an Event Bus? An event bus is similar to an Array, over time. Our particular event bus Omnibus is a wrapper around an RxJS Subject, with a slightly different API. You trigger
and listen
rather than next
and subscribe
. One excellent use of an Event Bus is to replace Redux middleware, like Redux Saga or Redux-Observable. A bus may carry any type, but for now let's assume it's duck-typing Redux, carrying only Flux Standard Actions.
Our scenario will use the bus for a typeahead search - like for querying an AJAX endpoint. Our bus will have a listener for Action<SearchRequest>
, and the listener will trigger actions of type Action<SearchResult>
back onto the bus. Our search may return many values - one SearchRequest
potentially triggering many SearchResult
actions. With explicit typing:
interface SearchRequest { query: string }
interface SearchResult { result: string }
const bus = new Omnibus<Action<any>>();
bus.listen<Action<SearchRequest>, Action<SearchResult>>(
(action) => (action.type === 'search/request'),
({ payload }) => { /* TODO use payload.query */
In the predicate function we know only that we have an Action<any>
- so that it can test any bus item. The listener is explicitly typed so it knows it has a SearchRequest
To continue started we'll need an endpoint. To work with Omnibus, it will return an Observable of SearchResult
from a query string
.
import { from } from 'rxjs';
const searchEndpoint : Observable<SearchResult> = (query: string) => from([
{result: 'abba'},
{result: 'apple'}
]);
We'll also be packaging results into Flux Standard Actions with the typescript-fsa
library, so let's use action creators for those types:
const searchAction = actionCreatorFactory('search');
const searchRequestCreator = searchAction<SearchRequest>('request');
const resultCreator = searchAction<SearchResult>('result');
These give us ways to create, and detect actions with a specified type. So let's wire them up.
Now, can we use implicit typing like the Fish/Zoo example above? We want the listener to know what type its action is, so that TypeScript can know that a query
property will be its event's payload. In other words, we'd like the type of the handler's argument to be narrowed by the predicate. What must the signature of listen
be to allow this?
We could try an explicit SubType type parameter:
public listen<SubType extends TBusItem>(
predicate: (item: TBusItem) => boolean,
handler: (item: SubType) => { ... }
But that would make us specify the SubType explicitly—it would not take advantage of type guards. Can it be more like our isFish
function? One little change, and the answer is yes. Here's the type-guarded version:
public listen<TMatchType extends TBusItem = TBusItem>(
predicate: (i: TBusItem) => i is TMatchType,
handler: (i: TMatchType) => { /* ... */ }
It allows for implicit typing by defaulting the TMatchType
to the supertype of all bus items. Yet, if a predicate narrows the type, our handler will act as if its argument of that narrowed type.
How do we put it all together? Thankfully, the .match
function on searchRequestCreator
provides a type predicate, not just a boolean return value! So when we plug it in, all the stars align:
bus.listen(
searchRequestCreator.match,
(({ payload: { query } }) => // we have .query!
searchEndpoint(query).pipe(
tap(result => {
bus.trigger(resultCreator(result))
)
)
)
We have a type-safe action in our listener now! I don't believe we need to type the return value of the listener - the bus will accept anything that is an Action<any>
, so we could even return action of many types. But if we wanted to we could specify listen<SearchResult>
just to ensure we don't introduce an action we weren't expecting..
Applications like Event Buses and Array filtering often cause you to lose some or all of type-safety. But with type predicates / type guards, it doesn't have to be this way. Enjoy stronger and more implicit typing by using them. And remember, explicit types are more expensive to write and maintain, so you can use the lint rule no-inferrable-types
(link), and further take extra syntax/noise out of your way!
40