Lately, I’ve become interested in different unidirectional user interface architectures. I’ve been working on a side project to implement or emulate some of these approaches in as little TypeScript as possible. The goal is to keep each implementation small and understandable, while still presenting a powerful, expressive API. My bigger goal is to learn as much as I can about why the libraries and patterns we reach for are the way they are. My favorite approaches so far have been CycleJS
, Elm
and Redux
with redux-observable
. One requirement that comes up in almost all of these architectures is the ability to match discriminated unions with cases. It’s usually useful to model actions or values in streams as discriminated unions. This is so that the values can be matched and passed into the correct handling functions.
A Missing Feature
In languages like Elm
, Swift
, F#
, Purescript
, Haskell
and many others, this sort of behavior is baked right into the language. Typescript
has a notion of discriminated unions, but it’s clear that they were hacked into the type system on top of a host language that doesn’t support the pattern natively.
Let’s look at an example. Say we’re working with something like redux-observable
and we have an action type like the following:
type AuthAction =
| { type: "login"; username: string; password: string }
| { type: "gotToken"; token: AccessToken }
| { type: "logout" | "refresh" };
And an login handler function of the following type:
const login: (args: {
username: string;
password: string;
}) => Promise<AccessToken>;
Notice that we can pass an AuthAction
directly to this function if type: "login"
because it defines the necessary fields. With that in mind, we might write an epic like so:
const authEpic = (action$: Observable<AuthAction>) =>
action$.pipe(
filter(a => a.type === "login"),
mergeMap((action) => from(login(action))), // Error:
// Argument of type 'AuthAction' is not assignable to
// parameter of type '{ username: string; password: string; }'.
map((token) => ({ type: "gotToken", token }))
);
The problem is that filter
only narrows the type if the argument to filter
is a type guard. If we split out the type: "login"
case into its own type, we could fix up our lambda to be:
(a): a is LoginAction => a.type === "login"
That’s a lot of boilerplate every time we want to narrow down a stream of actions, though. We need a unique, named type for each case we want to narrow to. We also have to repeat the entire structure of that lambda every time we call filter to keep the compiler happy. Can we do better?
The Solution
Yep! Here’s the code that makes it happen:
type Narrow<
Union extends { [_ in TagProp]: string },
TagProp extends string,
Tag extends string
> = Union extends { [_ in TagProp]: Tag }
? Union
: Tag extends Union[TagProp]
? Omit<Union, TagProp> & { [_ in TagProp]: Tag }
: never;
const narrow = <
Union extends { [_ in TagProp]: string },
TagProp extends string,
Tag extends Union[TagProp]
>(
u: Union,
tagProp: TagProp,
tag: Tag
): u is Narrow<Union, TagProp, Tag>
=> u[tagProp] === tag;
const is =
<TagProp extends string>(tagProp: TagProp) =>
<
Union extends { [_ in TagProp]: string },
Tag extends Union[TagProp]
>(tag: Tag)
=> (u: Union): u is Narrow<Union, TagProp, Tag>
=> narrow(u, tagProp, tag);
const of =
<TagProp extends string>(tagProp: TagProp) =>
<
Union extends { [_ in TagProp]: string },
Tag extends Union[TagProp]
>(tag: Tag)
=> filter(is<TagProp>(tagProp)<Union, Tag>(tag));
const isType = is("type");
const ofType = of("type");
Examples
Now our epic is happy!
const authEpic = (action$: Observable<AuthAction>) =>
action$.pipe(
ofType("login"),
mergeMap((action) => from(login(action))), // no more error!
map((token) => ({ type: "gotToken", token }))
);
Here are some other examples outside the context of rxjs
:
const sayHello = (args: { username: string })
=> `Hello ${args.username}!`;
const maybeLogin = (action: AuthAction) => {
if (narrow(action, "type", "login")) {
console.log(sayHello(action));
}
};
const sayHelloToLogins = (actions: AuthAction[]) =>
actions.filter(isType("login")).map(sayHello);
The Narrow
type above was inspired by this discussion. I ran into the same issue as others in that thread where my union types would narrow to never
if I had cases like { type: "logout" | "refresh" }
. This extra branch in the conditional type ensures that such types still narrow correctly:
Tag extends Union[TagProp]
? Omit<Union, TagProp> & { [_ in TagProp]: Tag }
: never;
This version is also generic to any TagProp
, instead of being tied to a particular name like tag
or type
.
Conclusion
Discriminated unions (also called union types or sum types in other languages) are powerful, ergonomic ways to express type safe business logic in your code, make illegal states unrepresentable, and compose a common design language that can be used across the entire product and development team. Typescript has discriminated unions, but they’re harder to work with than in some other statically typed languages. I hope this post helped you make better use of a great abstraction in your next Typescript project!
your snippet doesn’t even compile!
Compiles with Typescript 4.4.4 and the following tsconfig:
Good luck!
It breaks under TS 4.5.4 because of this condition:
Tag extends Union[TagProp]
? Omit & { [_ in TagProp]: Tag }
: never;
Wooh, this code is so much unreadable
Nick Keuning, any suggestions to make it work in the current version of typescript (see guy’s response)?