DEV Community

Anthony G
Anthony G

Posted on • Edited on

fp-ts and Even More Beautiful API Calls (w/ sum types!)

Multiple cases of bonzai

Contents

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`
    ),
  )
)();
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
);
Enter fullscreen mode Exit fullscreen mode

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}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
      )
  }
}
Enter fullscreen mode Exit fullscreen mode

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'))
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ),
})
Enter fullscreen mode Exit fullscreen mode

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))
);
Enter fullscreen mode Exit fullscreen mode

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'),
)
Enter fullscreen mode Exit fullscreen mode
// 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)
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • select
const NetworkError = AppError.select(['NoEndpoint', 'BadRequestBody', 'DatabaseError'])
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode
  • Verification (narrowing to a subset)
const error: AppError = ...
const a: string = pipe(
  error as NetworkError,
  O.fromPredicate(NetworkError.verified),
  O.map((networkError: NetworkError) => {
    ...
  })
)
Enter fullscreen mode Exit fullscreen mode

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 or unionADT
  • 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')
})
Enter fullscreen mode Exit fullscreen mode

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:

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).


  1. Under the hood, Typescript enum actually maps each case to a number, 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.

  2. 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 BirthdayPartyTheme can either be a Vehicle or a Color. We would add the number of possible Vehicles to the number of possible Colors.

    3 + 3 = 6

    How about the number of different possible Rides?

    A Ride must have both a Vehicle and a Color. We would multiply the terms instead.

    3 * 3 = 9

    This is why BirthdayPartyTheme is called a sum type and Ride is called a product type - 'sum' refers to addition and 'product' refers to multiplication.

    In fact, both Vehicle and Color 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.

  3. 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.

  4. boolean is technically a union type not a sum type, but it's functionally a sum type. 

Top comments (3)

Collapse
 
sirmoustache profile image
SirMoustache

Great article. I would like to see more of this kind of real-world examples.

Collapse
 
anthonyjoeseph profile image
Anthony G

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

Collapse
 
sirmoustache profile image
SirMoustache

Already following :)