Getting started with fp-ts: IO
Giulio Canti
Posted on April 14, 2019
In fp-ts
a synchronous effectful computation is represented by the IO
type, which is basically a thunk, i.e. a function with the following signature: () => A
interface IO<A> {
(): A
}
Note that IO
represents a computation that never fails.
Examples of such computations are:
- read / write to
localStorage
- get the current time
- write to the
console
- get a random number
Example (read / write to localStorage
)
import { fromNullable, Option } from 'fp-ts/Option'
const getItem = (key: string): IO<Option<string>> => () =>
fromNullable(localStorage.getItem(key))
const setItem = (key: string, value: string): IO<void> => () =>
localStorage.setItem(key, value)
Example (get the current time)
const now: IO<number> = () => new Date().getTime()
Example (write to the console
)
const log = (s: unknown): IO<void> => () => console.log(s)
Example (get a random number)
const random: IO<number> = () => Math.random()
The IO
type admits a Monad instance, so you can map
...
import { io } from 'fp-ts/IO'
/** get a random boolean */
const randomBool: IO<boolean> = io.map(random, n => n < 0.5)
...or chain
computations
/** write to the `console` a random boolean */
const program: IO<void> = io.chain(randomBool, log)
program()
Note that nothing happens until you call program()
.
That's because program
is a value which just represents an effectful computation, so in order to execute any side effect you must "run the IO
action".
Since IO
actions are just values you can use useful abstractions like Monoid to handle them...
Example (Dungeons and Dragons)
import { log } from 'fp-ts/Console'
import { getMonoid, IO, io } from 'fp-ts/IO'
import { fold, Monoid, monoidSum } from 'fp-ts/Monoid'
import { randomInt } from 'fp-ts/Random'
type Die = IO<number>
const monoidDie: Monoid<Die> = getMonoid(monoidSum)
/** returns the sum of the roll of the dice */
const roll: (dice: Array<Die>) => IO<number> = fold(monoidDie)
const D4: Die = randomInt(1, 4)
const D10: Die = randomInt(1, 10)
const D20: Die = randomInt(1, 20)
const dice = [D4, D10, D20]
io.chain(roll(dice), result => log(`Result is: ${result}`))()
/*
Result is: 11
*/
..or define useful combinators
/** Log any value to the console for debugging purposes */
const withLogging = <A>(action: IO<A>): IO<A> =>
io.chain(action, a => io.map(log(`Value is: ${a}`), () => a))
io.chain(roll(dice.map(withLogging)), result => log(`Result is: ${result}`))()
/*
Value is: 4
Value is: 2
Value is: 13
Result is: 19
*/
Error handling
What if we want to represent a synchronous effectful computation that may fail?
We need two effects:
Type constructor | Effect (interpretation) |
---|---|
IO<A> |
a synchronous effectful computation |
Either<E, A> |
a computation that may fail |
The solution is to put Either
inside IO
, which leads to the IOEither
type
interface IOEither<E, A> extends IO<Either<E, A>> {}
When we "run" a value of type IOEither<E, A>
, if we get a Left
it means that the computation failed with an error of type E
, otherwise we get a Right
which means that the computation succeeded with a value of type A
.
Example (read a file)
Since fs.readFileSync
may throw, I'm going to use the tryCatch
helper
tryCatch: <E, A>(f: () => A) => IOEither<E, A>
where f
is a thunk that either throws an error (which is automatically catched by tryCatch
) or returns a value of type A
.
import { toError } from 'fp-ts/Either'
import { IOEither, tryCatch } from 'fp-ts/IOEither'
import * as fs from 'fs'
const readFileSync = (path: string): IOEither<Error, string> =>
tryCatch(() => fs.readFileSync(path, 'utf8'), toError)
readFileSync('foo')() // => left(Error: ENOENT: no such file or directory, open 'foo')
readFileSync(__filename)() // => right(...)
Lifting
The fp-ts/IOEither
module provides other helpers which allow to create values of type IOEither
, they are collectively called lifting functions.
Here's a summary
Start value | lifting function |
---|---|
IO<E> |
leftIO: <E, A>(ml: IO<E>) => IOEither<E, A> |
E |
left: <E, A>(e: E) => IOEither<E, A> |
Either<E, A> |
fromEither: <E, A>(ma: Either<E, A>) => IOEither<E, A> |
A |
right: <E, A>(a: A) => IOEither<E, A> |
IO<A> |
rightIO: <E, A>(ma: IO<A>) => IOEither<E, A> |
Example (loading a random file)
Let's say we want to randomly load the content of one of three files (1.txt
, 2.txt
, 3.txt
).
The randomInt: (low: number, high: number) => IO<number>
function returns a random integer uniformly distributed in the closed interval [low, high]
import { randomInt } from 'fp-ts/Random'
We can chain randomInt
with the readFileSync
function defined above
import { ioEither } from 'fp-ts/IOEither'
const randomFile = ioEither.chain(
randomInt(1, 3), // static error
n => readFileSync(`${__dirname}/${n}.txt`)
)
This doesn't type check though!
The types don't align: randomInt
runs in the IO
context while readFileSync
runs in the IOEither
context.
However we can lift randomInt
to the IOEither
context by using rightIO
(see the summary above)
import { ioEither, rightIO } from 'fp-ts/IOEither'
const randomFile = ioEither.chain(rightIO(randomInt(1, 3)), n =>
readFileSync(`${__dirname}/${n}.txt`)
)
Posted on April 14, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024