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 ParseError
s 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/adt
3
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
select
orunionADT
- 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
any
type, 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 - boolean
4, 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
enum
actually maps each case to anumber
, so this is valid: ↩const error: ErrorType = 3
Be 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
BirthdayPartyTheme
s there are?A
BirthdayPartyTheme
can either be aVehicle
or aColor
. We would add the number of possibleVehicle
s to the number of possibleColor
s.3 + 3 = 6
How about the number of different possible
Ride
s?A
Ride
must have both aVehicle
and aColor
. We would multiply the terms instead.3 * 3 = 9
This is why
BirthdayPartyTheme
is called a sum type andRide
is called a product type - 'sum' refers to addition and 'product' refers to multiplication.In fact, both
Vehicle
andColor
are sum types as well.1 + 1 + 1 = 3
What's counterintuitive is that a
Yellow
themed birthday party can actually be a lot of fun. -
The 'adt' in
@morphic-ts/adt
stands for 'Algebraic Data Type'.morphic
uses 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. -
boolean
is 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 :)