Enough fp-ts to work with io-ts
Originally posted on dev.to/gillchristian.
This is a small tutorial of what one needs to know about fp-ts to work with io-ts. In other words:
Just enough fp-ts to
to work with io-tsbe dangerous
what-ts now ?
Let's start by introducing these two libraries and what they are.
fp-ts
Typed functional programming in TypeScript
... a library containing implementations of common algebraic types in TypeScript
That seems to be clear. Ok, maybe not the algebraic types but that's not super important in practice.
io-ts
Runtime type system for IO decoding/encoding
Urhg, what?!
io-ts provides safe encoding/decoding. We can say is the proper way to do JSON.parse
with types in mind. Check the library docs to learn more about it.
But why?
io-ts is part of the ecosystem of typed functional programming (FP) in TypeScript, fp-ts being the main library. So naturally io-ts does good use of fp-ts.
If you are familiar with (typed) FP, then why are you still reading? Or maybe you can finish and suggest some improvements to this post π
On the other hand, if you aren't familiar with (typed) FP and see the fp-ts website it might seem scary and feel like a lot of information to process. Specially the weird words. Well it is a bit scary, I agree with you. But for working with io-ts we need just a really small smol fraction of the library. Besides that, my advice is to approach it as you would approach any other API of a library you learning to use. I'm not gonna go full advocate of FP mode, just say that it's really worth to learn and a pleasure to work with.
Either it's right or left
When decoding a type with io-ts the weird new thing we see is, it returns a value of type Either<t.Errors, T>
(where T
is the type we want to decode to).
import * as t from 'io-ts'
const result: Either<t.Errors, number> = t.number.decode(123)
Either is an algebraic type, in particular a discriminated union with two branches, Right
and Left
. The first is usually used to denote success and the later to denote failure. In our example t.Errors
is the type wrapped by Left and number
is wrapped by Right. Either is a sum type (another term for discriminated unions) because it can be either (pun intended) Left or Right, but not both.
The purpose of Either, as you probably guessed, is to work with operations that can fail or succeed. And it provides some nice features to work with it.
fold
ing π
Although one could access the internals of an Either value to get it's contents, that's not how it's intended to work.
To get a value out of it, fp-ts provides fold
:
declare function fold<E, A, B>(
onLeft: (e: E) => B,
onRight: (a: A) => B,
): (e: Either<E, A>) => B
As the type definition implies, it expects us to handle both cases.
There are some similarities between fold
and Array.reduce
, which is also a way to transform a wrapped type (Array<A>
) into a single value (B
).
declare function reduce<A, B>(
f: (acc: B, current: A) => B,
initialValue: B,
): (arr: Array<A>) => B
NOTE: I changed the signature of Array.reduce
to match the signature of Either's fold.
We are going to use fold
mostly as the way to consume an Either. Eg. after decoding an API response with io-ts you'll either dispatch a success action (onRight
) or a failure one and report to Honeybadger the error (onLeft
).
You'll see that most (if not all) of the algebraic types that fp-ts defines have their own version of fold
π
map
ing πΊοΈ
Once we have fold
in our toolbelt we might be tempted to extract values out of Either every time we want to work with them.
What if I told you, you don't have to?
Just like Array.map
, there's also map
defined for Either.
// Array's map
declare function map<A, B>(f: (a: A) => B): (arr: Array<A>) => Array<B>
// Either's map
declare function map<A, B>(f: (a: A) => B): <E>(e: Either<E, A>) => Either<E, B>
NOTE: I changed the signature of Array.map
to match Either's one.
As we can see, it allows us to transform the Right value of an Either by applying a function to its contents. If our value happens to be a Left, it won't be changed. And we can keep working happily with our wrapped value, no fold
ing required.
What about transforming the Left value you ask? mapLeft
to the rescue, and this time the unchanged one is the Right.
declare function mapLeft<E, G>(
f: (e: E) => G,
): <A>(fa: Either<E, A>) => Either<G, A>
Constructing Eithers π·ββοΈ
The last thing we need to know about Either is how to construct values.
import {left, right, Either} from 'fp-ts/lib/Either'
declare function left<E = never, A = never>(e: E): Either<E, A>
declare function right<E = never, A = never>(a: A): Either<E, A>
const aRight: Either<never, number> = right(123)
const aLeft: Either<string, never> = left('some error message')
Go Through The Pipes
Although strictly that's all we need from fp-ts to work with io-ts, we are also going to be using a few utilities it provides to work with functions. It's a FP library after all, right?
pipe πΏ
This is one of the ways to do function composition in fp-ts. It let's us pipe a value through a list of functions, where the next function is called with the result of the previous one.
Take this case for example:
export const decodeWith = <A>(decoder: Decoder<unknown, A>) => (
response: unknown,
) =>
mapLeft((errors) => ({tag: 'decoding', errors} as const))(
humanizeErrors(decoder.decode(response)),
)
We decode a response, humanize the potential errors and then map the left to another type.
With pipe it would look like this:
import { pipe } from 'fp-ts/lib/pipeable';
export const decodeWith = <A>(decoder: Decoder<unknown, A>) => (
response: unknown,
) =>
pipe(
response,
decoder.decode,
humanizeErrors,
mapLeft((errors) => ({tag: 'decoding', errors} as const)),
)
Now the code reads just like the description:
decode a response, humanize the potential errors and then map the left to another type.
One good thing about pipe is, it's ready for when the pipeline operator proposal lands in JS. Sadly, judging by the pace TC39 works at, that might take years.
-import { pipe } from 'fp-ts/lib/pipeable';
-
export const decodeWith = <A>(decoder: Decoder<unknown, A>) => (
response: unknown,
) =>
- pipe(
- response,
- decoder.decode,
- humanizeErrors,
- mapLeft((errors) => ({tag: 'decoding', errors} as const)),
- )
+ response
+ |> decoder.decode
+ |> humanizeErrors
+ |> mapLeft(errors => ({ tag: 'decoding', errors } as const))
flow βοΈ
flow
is right to left function composition. Very similar to pipe, but doesn't take the value as first argument but instead returns a function.
-import { pipe } from 'fp-ts/lib/pipeable';
+import { flow } from 'fp-ts/lib/function';
-export const decodeWith = <A>(decoder: Decoder<unknown, A>) => (
- response: unknown,
-) =>
- pipe(
+export const decodeWith = <A>(decoder: Decoder<unknown, A>) =>
+ flow(
- response,
decoder.decode,
humanizeErrors,
mapLeft((errors) => ({tag: 'decoding', errors} as const)),
)
That's it but there's more
As far as using io-ts, that's all we need to know about fp-ts (for now at least). If you are already bored, no need to keep on reading.
Although we could use fp-ts (and it's ecosystem) for many other things, this is a great start. By decoding all IO stuff (a.k.a. API responses) we make sure to remove unexpected runtime behavior and errors because we promised the compiler a JSON.parse
returns something when in reality it doesn't.
If you see the value on it and want to learn more about (typed) FP check out the Core Concepts and Learning Resources sections in the fp-ts docs. And more importantly make sure to ask questions and look for other people that are in the path of learning about (typed) FP as well.
No M word? π―
Somehow I managed to write about FP without using the word monad (well, now I did). That is because there was no need to do so. Even though Either is a monad, we didn't use any of it's monadic properties. We did use it as a Functor. A functor is an algebraic type that has map
defined to it, which has to abide some laws to make sure every functor instance is consistent. You wouldn't want Either's map behaving differently than Array's map. Oh and yeah, Array is also a Functor.
Once again, learning these terms (monad, functor, applicative, foldable, et al) is not necessary. But, if you are interested, it can be really useful. For example, if you know that Array has map defined because it's a functor. And then discover a new algebraic type (like Either) that is also a functor you'll already know how to work with the map for that type.
Ok, now that's it.
Happy and safe coding π€