Contents
- The Prompt
- Union types to the rescue!
- Unions of Complex Types - Sum Types
-
Even more features with
@morphic-ts/adt - Is it Actually Boilerplate
- How far we've come
- Conclusion
The Prompt
Let's build on the application outlined in fp-ts and Beautiful API Calls (a lovely application to be sure).
We want to be able to customize error messages based on their source:
const handleErrors = (error: Error): T.Task<string> => {
const message: string = ...
if (/* error is from parsing */) {
return T.of(`Parse error: ${message}`)
} else if (/* error is from networking */) {
return T.of(`Network error: ${message}`)
}
}
const runProgram = pipe(
sequenceT(TE.taskEither)(
getAnswer,
getFromUrl(apiUrl(1), users),
getFromUrl(apiUrl(2), users)
),
TE.fold(
handleErrors,
([ans, users1, users2]) => T.of(
smashUsersTogether(users1, users2).join(", ")
+ `\nThe answer was ${ans.ans} for all of you`
),
)
)();
As it stands, implementing handleErrors is difficult, since its input is of type Error. This means that we'll have to parse the string at error.message.
const handleErrors = (error: Error): T.Task<string> => {
const message: string = ...
if (error.message.includes('parse')) {
return T.of(`Parse error: ${message}`)
} else if (error.message.includes('network')) {
return T.of(`Network error: ${message}`)
}
return T.of('can never happen')
}
This is a brittle solution. What if we forget to handle a case or misspell something? And why should we have to handle a case that can never happen?
Union types to the rescue!
const enum ErrorType {
Network,
Parse,
}
interface AppError {
type: ErrorType
message: string
}
Here, ErrorType is a union type. A union type encodes an 'or' relationship, meaning that an ErrorType is either Network or Parse, but never both. Right now we're using a simple typescript enumerator to implement our union type, but there are other ways of doing it1.
We'll need to rewrite our networking code to conform to our new AppError interface:
const decodeWith = <A>(decoder: t.Decoder<unknown, A>) =>
flow(
decoder.decode,
E.mapLeft((errors): AppError => ({
type: ErrorType.Parse,
message: failure(errors).join('\n')
})),
TE.fromEither
)
const getFromUrl = <A>(url:string, codec:t.Decoder<unknown, A>) => pipe(
httpGet(url),
TE.map(x => x.data),
TE.mapLeft(({ message }): AppError => ({ type: ErrorType.Network, message })),
TE.chain(decodeWith(codec))
);
Now implementing handleErrors is a breeze!
const handleErrors = (appError: AppError): T.Task<string> => {
switch(appError.type) {
case ErrorType.Network:
return T.of(`Network error: ${appError.message}`)
case ErrorType.Parse:
return T.of(`Parse error: ${appError.message}`)
}
}
Benefits
We get autocompletion when we type in ErrorType. - our IDE recommends ErrorType.Network and ErrorType.Parse to us.
Furthermore, we get an exhaustiveness check from the switch statement, so this won't compile:
const handleErrors = (appError: AppError): T.Task<string> => {
switch(appError.type) {
case ErrorType.Network:
return T.of(`Network error: ${appError.message}`)
}
}
// Function lacks ending return statement and return type does not include 'undefined'.
Like anything that adds type-safety, ErrorType helps you make a visible contract for yourself up front and hold yourself to it later on.
Unions of Complex Types - Sum Types
What if we want to display the first three lines of our ErrorType.Parse errors to the user, and log the full error for the developer?
We need an AppError type that can handle both situations.
interface AppError {
type: ErrorType
message: string
firstThreeLines: string | undefined
}
This isn't a great solution. We have duplicate data all over the place. firstThreeLines is just the first three lines of message, and a value of undefined for firstThreeLines is the same as a value of ErrorType.Network for type. There is no SSOT.
It would be simplest if we could keep track of the original t.Errors value returned by io-ts
interface AppError {
type: ErrorType
message: string | t.Errors
}
This works, but we'll have to do an ugly typeof check to determine what message is. And once again we have duplicate data: if our message is a string, our type should never be ErrorType.Parse. Our metadata is duplicated. We can do bad stuff like this:
const absurdError: AppError = {
type: ErrorType.Parse,
message: 'not an io-ts error' // we can have a 'string' here!
}
Is there a way to prevent these potential mistakes at compile time?
Let's ditch ErrorType and try something radical.
interface NetworkError {
type: 'NetworkError'
message: string
}
interface ParseError {
type: 'ParseError'
errors: t.Errors
}
type AppError = NetworkError | ParseError
How does this make our code look?
const handleErrors = (appError: AppError): T.Task<string> => {
switch(appError.type) {
case 'NetworkError':
return T.of(`Network error: ${appError.message}`)
case 'ParseError':
return pipe(
appError.errors,
failure,
A.takeLeft(3),
a => a.join('\n'),
T.of
)
}
}
Like magic, Typescript can infer which data our appError contains depending on which case is being handled. Calling appError.errors outside the scope of case 'ParseError': would be an error, but inside the scope it typechecks.
This is called a tagged union type, or a sum type2. Here, our tag is type.
And we have the flexibility to handle ParseErrors differently in different cases:
const logAllParseErrors = (appError: AppError): void => {
if (appError.type === 'ParseError') {
console.error(failure(appError.errors).join('\n'))
}
}
The underlying upgrade here is that AppError is now able to contain completely different data depending on which case it represents.
Even more features with @morphic-ts/adt3
If we don't like the switch statement's statement-oriented appearance, we can get a more expression-oriented appearance using matchStrict from @morphic-ts/adt
import { makeADT, ofType, ADT, ADTType } from '@morphic-ts/adt'
// little bit of boilerplate here
const AppError: ADT<NetworkError | ParseError, "type"> = makeADT('type')({
NetworkError: ofType<NetworkError>(),
ParseError: ofType<ParseError>(),
})
type AppError = ADTType<typeof AppError>
const handleErrors = AppError.matchStrict<T.Task<string>>({
NetworkError: ({ message }) => T.of(`Network error: ${message}`),
ParseError: ({ errors }) => pipe(
errors,
failure,
A.takeLeft(3),
a => a.join('\n'),
T.of
),
})
makeADT's first parameter is our tagged union's 'tag'. For this example, our tag is 'type'.
You might also notice that we have two entities called AppError: a const and a type. The const is morphic's magical ADT value that gives us fancy type-safe operations like matchStrict. The compiler is able to infer from context which of the two AppError entities to use, and you only have to export & import AppError once to be able to use both entities.
We also get a nice constructor syntax:
const decodeWith = <A>(decoder: t.Decoder<unknown, A>) =>
flow(
decoder.decode,
E.mapLeft((errors) => AppError.of.ParseError({ errors })),
TE.fromEither
)
const getFromUrl = <A>(url:string, codec:t.Decoder<unknown, A>) => pipe(
httpGet(url),
TE.map(x => x.data),
TE.mapLeft(({ message }) => AppError.of.NetworkError({ message })),
TE.chain(decodeWith(codec))
);
In my experience, the curried pattern match provided by matchStrict makes the @morphic-ts/adt boilerplate worth it for most sum types.
Other advantages of @morphic-ts/adt
We get many features for free:
const error: AppError = ...
let message: string = 'default'
if (AppError.is.NetworkError(error)) {
// the type is narrowed, so we have access to `message`
message = error.message
}
// or
import * as O from 'fp-ts/Option'
const message = pipe(
error,
O.fromPredicate(AppError.is.NetworkError),
O.map(e => e.message),
O.getOrElse(() => 'default'),
)
// add data to the cases
interface NetworkError {
type: 'NetworkError'
message: string
metaData: {
errorID: number
}
}
interface ParseError {
type: 'ParseError'
errors: t.Errors
errorID: number
}
// ...
import * as M from 'monocle-ts'
const errorIDLens: (a: AppError) => number = AppError.matchLens({
NetworkError: M.Lens.fromPath<NetworkError>()(['metaData', 'errorID']),
ParseError: M.Lens.fromProp<ParseError>()('errorID'),
})
const error: AppError = ...
const errorID: number = errorIDLens.get(error)
-
unionADT
interface NoEndpoint ...
interface BadRequestBody ...
interface DatabaseError ...
interface ParseError ...
const NetworkError = makeADT('type')({
NoEndpoint: ofType<NoEndpoint>(),
BadRequestBody: ofType<BadRequestBody>(),
DatabaseError: ofType<DatabaseError>(),
})
type NetworkError = ADTType<typeof NetworkError>
const AppError = unionADT([
NetworkError,
makeADT('type')({
ParseError: ofType<ParseError>(),
}),
])
type AppError = ADTType<typeof AppError>
-
exclude
const AppError = makeADT('type')({
NoEndpoint: ofType<NoEndpoint>(),
BadRequestBody: ofType<BadRequestBody>(),
DatabaseError: ofType<DatabaseError>(),
ParseError: ofType<ParseError>(),
})
type AppError = ADTType<typeof AppError>
const NetworkError = AppError.exclude(['ParseError'])
type NetworkError = ADTType<typeof NetworkError>
-
select
const NetworkError = AppError.select(['NoEndpoint', 'BadRequestBody', 'DatabaseError'])
- default match case
// type widening as well
// handles multiple different return types
const matcher: (a: AppError) => string | number = AppError.match(
{
NoEndpoint: ({ endpoint }) => `bad endpoint: ${endpoint}`,
BadRequestBody: () => 3,
},
() => 'other'
)
const error: AppError = ...
const result: string | number = matcher(error)
- Verification (narrowing to a subset)
const error: AppError = ...
const a: string = pipe(
error as NetworkError,
O.fromPredicate(NetworkError.verified),
O.map((networkError: NetworkError) => {
...
})
)
The added boilerplate is unfortunate but minimal, and the generated ADT type is powerful. More powerful even than sum type manipulation in Haskell: The equivalent of matchLens requires a Haskell language extension.
Additionally, morphic can solve many of the problems outlined in Matt Parsons's wonderful The Trouble with Typed Errors.
To oversimplify, Parsons makes the correct point that monolithic error types are bad. With morphic, you can:
- keep your types small with
selectorunionADT - pass errors upstream with
validate
Is it Actually Boilerplate
Functions like getFromUrl and decodeWith can seem like boilerplate. Shouldn't every application have to write something like this? Surely there must be existing npm packages out there that do this type of thing for you.
There are several out there (like fetch-ts, fp-fetch, & react-fetchable). My current favorite is appy because it accurately models javascript's fetch and composes nicely with io-ts. However, though I normally discourage re-inventing the wheel, I often prefer to write this kind of code on my own.
Most of the work in converting Promise into TaskEither is in deciding how granular your Error type needs to be. This is necessarily tied to each individual project's requirements. Some projects might be simple enough to display an "Error, please try again" message for everything, while some might need to log each error's http status code. It's also worth mentioning that axios has different mocking capabilities than fetch and might not always be appropriate for every project.
How far we've come
Here's a naive implementation of the original program, without type safety (it's a one liner):
const runProgram2 = Promise.all([
Promise.resolve({ ans: 42 }),
fetch('https://reqres.in/api/users?page=1').then(a => a.json()),
fetch('https://reqres.in/api/users?page=2').then(a => a.json()),
]).then(([ans, users1, users2]) =>
[...users1.data, ...users2.dat]
.map(item => item.firstName)
.join(', ')
+ `\nThe answer was ${ans.ans} for all of you`
).catch(error => error.message
.startsWith('Cannot read property')
? console.error('Parse error')
: console.error('Network error')
})
I must admit, the naive solution is attractive in its simplicity. We didn't even have to import anything. We could have saved some time writing the code like this instead.
What's better about our earlier type-safe solution?
- Due to the use of the
anytype, there are typos in the above code that the compiler wouldn't catch - We aren't relying on the nuances of runtime error messages
- We're able to output specific parsing errors
- we can differentiate errors at compile time with exactly the granularity we want
- our function signatures tells us which specific errors must be handled
- we get compile time errors if we fail to handle every case
- autocompletion every step of the way
Conclusion
Sum types are worth learning about! They have many applications on the frontend alone:
- routing (check out my article!)
- loading indicators
- radio buttons
- representing a day of the week
- representing logged in vs anonymous users
-
redux actions (morphic even provides
createReducer) - etc.
And many types you may already know - boolean4, Option, Either, These - are sum types. The concept of a sum type (or tagged union) is a fundamental concept in data modeling (and math).
So even if you decide your application's error handling is simple enough that you don't want the added complexity, in the future you'll know how to model 'or' relationships between complex data at the type level.
If you're interested in learning more, here's a comprehensive article by Gabriel Lebec (warning: paywall).
-
Under the hood, Typescript
enumactually maps each case to anumber, so this is valid: ↩const error: ErrorType = 3Be careful of this! This can lead to unexpected behavior.
They are actually quite flexible in this regard, which makes them less referentially transparent.
In practice, sometimes I prefer to use a union type of string literals to increase referential transparency, although this can be a little less legible.
-
The name 'sum type' refers to the number of possibilities it can represent. They exist in contrast to product types (e.g. Typescript interfaces and tuples) (they are mathematical duals). Take the following example: ↩
type Vehicle = 'Car' | 'Motorcycle' | 'Truck'
type Color = 'Yellow' | 'Red' | 'Blue'
type BirthdayPartyTheme = Vehicle | Color
interface Ride { wheels: Vehicle; style: Color }What if we wanted to find out how many different possible
BirthdayPartyThemes there are?A
BirthdayPartyThemecan either be aVehicleor aColor. We would add the number of possibleVehicles to the number of possibleColors.3 + 3 = 6How about the number of different possible
Rides?A
Ridemust have both aVehicleand aColor. We would multiply the terms instead.3 * 3 = 9This is why
BirthdayPartyThemeis called a sum type andRideis called a product type - 'sum' refers to addition and 'product' refers to multiplication.In fact, both
VehicleandColorare sum types as well.1 + 1 + 1 = 3What's counterintuitive is that a
Yellowthemed birthday party can actually be a lot of fun. -
The 'adt' in
@morphic-ts/adtstands for 'Algebraic Data Type'.morphicuses the term a bit differently than it's classical definition. ↩According to wikipedia,
an algebraic data type is a kind of composite type, i.e., a type formed by combining other types
The term is often used in programming to refer to either product types (see above) or sum types, although pi types and sigma types are also technically ADTs (they rely on dependent types which is a whole other can of worms).
In
morphic, 'ADT' always refers to sum types. -
booleanis technically a union type not a sum type, but it's functionally a sum type. ↩

Top comments (3)
Great article. I would like to see more of this kind of real-world examples.
Thank you! If you want to read more, I have many more articles on this site and I recommend Ryan Lee's Practical Guide to fp-ts
Already following :)