Introduces an asynchronous boundary at the current stage in the asynchronous processing pipeline (after the source has been evaluated).
Consider the following example:
const readPath: () => "path/to/file"
const io = IO.of(readPath)
.asyncBoundary()
.map(fs.readFileSync)
Between reading the path and then reading the file from that
path, we schedule an async boundary (it usually happens with
JavaScript's setTimeout
under the hood).
This is equivalent with:
self.flatMap(a => IO.shift(ec).map(_ => a))
// ... or ...
self.forEffect(IO.shift(ec))
is an optional Scheduler
implementation that can
be used for scheduling the async boundary, however if
not specified, the IO
's default scheduler (the one
passed to run()
) gets used
Handle errors by lifting results into Either
values.
If there's an error, then a Left
value will be signaled. If
there is no error, then a Right
value will be signaled instead.
The returned type is an Either value, which is what's called a "logical disjunction" or a "tagged union type", representing a choice between two values, in this case errors on the "Left" and successful results on the "Right".
// Describing an IO that can fail on execution:
const io: IO<number> = IO.of(() => {
const n = Math.random() * 1000
const m = n & n // to integer
if (m % 2) throw new Error("No odds please!")
return m
})
// By using attempt() we can observe and use errors
// in `map` and `flatMap` transformations:
io.attempt().map(either =>
either.fold(
err => "odd",
val => "even"
))
For other error handling capabilities, see IO.recoverWith and IO.transformWith.
Delays the evaluation of this IO
by the specified duration.
const fa = IO.of(() => "Hello")
// Delays the evaluation by 1 second
fa.delayExecution(1000)
is the duration to wait before signaling the final result
Delays signaling the result of this IO
on evaluation by the
specified duration.
It works for successful results:
const fa = IO.of(() => "Alex")
// Delays the signaling by 1 second
fa.delayResult(1000)
And for failures as well:
Future.raise(new TimeoutError()).delayResult(1000)
is the duration to wait before signaling the final result
Returns a new IO
that will mirror the source, but that will
execute the given callback
if the task gets canceled before
completion.
This only works for premature cancellation. See IO.doOnFinish for triggering callbacks when the source finishes.
is the IO
value to execute if the task gets
canceled prematurely
Returns a new IO
in which f
is scheduled to be run on
completion. This would typically be used to release any
resources acquired by this IO
.
The returned IO
completes when both the source and the task
returned by f
complete.
NOTE: The given function is only called when the task is complete. However the function does not get called if the task gets canceled. Cancellation is a process that's concurrent with the execution of a task and hence needs special handling.
See IO.doOnCancel for specifying a callback to call on canceling a task.
Ensures that an asynchronous boundary happens before the execution, managed by the provided scheduler.
Alias for IO.fork.
Calling this is equivalent with:
IO.shift(ec).flatMap(_ => self)
// ... or ...
IO.shift(ec).followedBy(self)
See IO.fork, IO.asyncBoundary and IO.shift.
Override the ExecutionModel
of the default scheduler.
import { ExecutionModel } from "funfix"
io.executeWithModel(ExecutionModel.alwaysAsync())
Returns a new IO
that upon evaluation will execute with the
given set of IOOptions, allowing for tuning the run-loop.
This allows for example making run-loops "auto-cancelable", an option that's off by default due to safety concerns:
io.executeWithOptions({
autoCancelableRunLoops: true
})
Creates a new IO
by applying a function to the successful
result of the source, and returns a new instance equivalent to
the result of the function.
const rndInt = IO.of(() => {
const nr = Math.random() * 1000000
return nr & nr
})
const evenInt = () =>
rndInt.flatMap(int => {
if (i % 2 == 0)
return IO.now(i)
else // Retry until we have an even number!
return evenInt()
})
Returns a new IO
that upon evaluation will execute the given
function for the generated element, transforming the source into
an IO<void>
.
Returns a new IO
that applies the mapping function to the
successful result emitted by the source.
IO.now(111).map(_ => _ * 2).get() // 222
Note there's a correspondence between flatMap
and map
:
fa.map(f) <-> fa.flatMap(x => IO.pure(f(x)))
Memoizes (caches) the result of the source IO
and reuses it on
subsequent invocations of run
.
The resulting task will be idempotent, meaning that evaluating the resulting task multiple times will have the same effect as evaluating it once.
Memoizes (caches) the successful result of the source task
and reuses it on subsequent invocations of run
.
Thrown exceptions are not cached.
The resulting task will be idempotent, but only if the result is successful.
Creates a new IO
that will mirror the source on success,
but on failure it will try to recover and yield a successful
result by applying the given function f
to the thrown error.
This function is the equivalent of a try/catch
statement,
or the equivalent of .map for errors.
io.recover(err => {
console.error(err)
fallback
})
Creates a new IO
that will mirror the source on success,
but on failure it will try to recover and yield a successful
result by applying the given function f
to the thrown error.
This function is the equivalent of a try/catch
statement,
or the equivalent of .flatMap for errors.
Note that because of IO
's laziness, this can describe retry
loop:
function retryOnFailure<A>(times: number, io: IO<A>): IO<A> {
return source.recoverWith(err => {
// No more retries left? Re-throw error:
if (times <= 0) return IO.raise(err)
// Recursive call, yes we can!
return retryOnFailure(times - 1, io)
// Adding 500 ms delay for good measure
.delayExecution(500)
})
}
Triggers the asynchronous execution.
Without invoking run
on a IO
, nothing gets evaluated, as an
IO
has lazy behavior.
// Describing a side effect
const io = IO.of(() => console.log("Hello!"))
// Delaying it for 1 second, for didactical purposes
.delayExecution(1000)
// Nothing executes until we call run on it, which gives
// us a Future in return:
const f: Future<void> = io.run()
// The given Future is cancelable, in case the logic
// decribed by our IO is cancelable, so we can do this:
f.cancel()
Note that run
takes a
Scheduler
as an optional parameter and if one isn't provided, then the
default scheduler gets used. The Scheduler
is in charge
of scheduling asynchronous boundaries, executing tasks
with a delay (e.g. setTimeout
) or of reporting failures
(with console.error
by default).
Also see IO.runOnComplete for a version that takes a callback as parameter.
a Future
that will eventually complete with the
result produced by this IO
on evaluation
Triggers the asynchronous execution.
Without invoking run
on a IO
, nothing gets evaluated, as an
IO
has lazy behavior.
runComplete
starts the evaluation and takes a callback which
will be triggered when the computation is complete.
Compared with JavaScript's Promise.then
the provided callback
is a function that receives a
Try value, a data
type which is what's called a "logical disjunction", or a "tagged
union type", a data type that can represent both successful
results and failures. This is because in Funfix we don't work
with null
.
Also the returned value is an
ICancelable
reference, which can be used to cancel the running computation,
in case the logic described by our IO
is cancelable (note that
some procedures cannot be cancelled, it all depends on how the
IO
value was described, see IO.async for how cancelable
IO
values can be built).
Example:
// Describing a side effect
const io = IO.of(() => console.log("Hello!"))
.delayExecution(1000)
// Nothing executes until we explicitly run our `IO`:
const c: ICancelable = io.runOnComplete(r =>
r.fold(
err => console.error(err),
_ => console.info("Done!")
))
// In case we change our mind and the logic described by
// our `IO` is cancelable, we can cancel it:
c.cancel()
Note that runOnComplete
takes a
Scheduler
as an optional parameter and if one isn't provided, then the
default scheduler gets used. The Scheduler
is in charge
of scheduling asynchronous boundaries, executing tasks
with a delay (e.g. setTimeout
) or of reporting failures
(with console.error
by default).
Also see IO.run for a version that returns a Future
,
which might be easier to work with, especially since a Future
is Promise
-like.
is the callback that will be eventually called with the final result, or error, when the evaluation completes
is the scheduler that controls the triggering of
asynchronous boundaries (e.g. setTimeout
)
a cancelable action that can be triggered to cancel
the running computation, assuming that the implementation
of the source IO
can be cancelled
Returns an IO
that mirrors the source in case the result of
the source is signaled within the required after
duration
on evaluation, otherwise it fails with a TimeoutError
,
cancelling the source.
const fa = IO.of(() => 1).delayResult(10000)
// Will fail with a TimeoutError on run()
fa.timeout(1000)
is the duration to wait until it triggers the timeout error
Returns an IO
value that mirrors the source in case the result
of the source is signaled within the required after
duration
when evaluated (with run()
), otherwise it triggers the
execution of the given fallback
after the duration has passed,
cancelling the source.
This is literally the implementation of IO.timeout:
const fa = IO.of(() => 1).delayResult(10000)
fa.timeoutTo(1000, IO.raise(new TimeoutError()))
is the duration to wait until it triggers the fallback
is a fallback IO
to timeout to
Creates a new IO
by applying the 'success' function to the
successful result of the source, or the 'error' function to the
potential errors that might happen.
This function is similar with .map, except that it can also transform errors and not just successful results.
is a function for transforming failures
is a function for transforming a successful result
Creates a new IO
by applying the 'success' function to the
successful result of the source, or the 'error' function to the
potential errors that might happen.
This function is similar with .flatMap, except that it can also transform errors and not just successful results.
is a function for transforming failures
is a function for transforming a successful result
Promote a thunk
function to an IO
, catching exceptions in
the process.
Note that since IO
is not memoized by global, this will
recompute the value each time the IO
is executed.
const io = IO.always(() => { console.log("Hello!") })
io.run()
//=> Hello!
io.run()
//=> Hello!
io.run()
//=> Hello!
Create a IO
from an asynchronous computation, which takes
the form of a function with which we can register a callback.
This can be used to translate from a callback-based API to a straightforward monadic version.
Constructs a lazy IO instance whose result will be computed asynchronously.
WARNING: Unsafe to use directly, only use if you know
what you're doing. For building IO
instances safely
see IO.async.
Rules of usage:
StackedCancelable
can be used to store
cancelable references that will be executed upon cancel;
every push
must happen at the beginning, before any
execution happens and pop
must happen afterwards
when the processing is finished, before signaling the
resultSuccess
or Failure
),
another async boundary is necessary, but can also
happen with the scheduler's facilities for trampolined
execution (e.g. Scheduler.trampoline
)WARNING: note that not only is this builder unsafe, but also unstable, as the IORegister callback type is exposing volatile internal implementation details. This builder is meant to create optimized asynchronous tasks, but for normal usage prefer IO.async.
Promote a thunk
function generating IO
results to an IO
of the same type.
Alias for IO.suspend.
Defers the creation of an IO
by using the provided function,
which has the ability to inject a needed Scheduler
.
Example:
function measureLatency<A>(source: IO<A>): IO<[A, Long]> {
return IO.deferAction<[A, Long]>(s => {
// We have our Scheduler, which can inject time, we
// can use it for side-effectful operations
const start = s.currentTimeMillis()
return source.map(a => {
const finish = s.currentTimeMillis()
return [a, finish - start]
})
})
}
is the function that's going to be called when the
resulting IO
gets evaluated
Given a thunk
that produces Future
values, suspends it
in the IO
context, evaluating it on demand whenever the
resulting IO
gets evaluated.
See IO.fromFuture for the strict version.
Wraps calls that generate Future
results into IO
, provided
a callback with an injected Scheduler
.
This builder helps with wrapping Future
-enabled APIs that need
a Scheduler
to work.
is the function that's going to be executed when the task
gets evaluated, generating the wrapped Future
Returns an IO
that on evaluation will complete after the
given delay
.
This can be used to do delayed execution. For example:
IO.delayedTick(1000).flatMap(_ =>
IO.of(() => console.info("Hello!"))
)
is the duration to wait before signaling the tick
Creates a race condition between multiple IO
values, on
evaluation returning the result of the first one that completes,
cancelling the rest.
const failure = IO.raise(new TimeoutError()).delayResult(2000)
// Will yield 1
const fa1 = IO.of(() => 1).delayResult(1000)
IO.firstCompletedOf([fa1, failure])
// Will yield a TimeoutError
const fa2 = IO.of(() => 1).delayResult(10000)
IO.firstCompletedOf([fa2, failure])
a new IO
that will evaluate to the result of the first
in the list to complete, the rest being cancelled
Mirrors the given source IO
, but before execution trigger
an asynchronous boundary (usually by means of setTimeout
on
top of JavaScript, depending on the provided Scheduler
implementation).
If a Scheduler
is not explicitly provided, the implementation
ends up using the one provided in IO.run.
Note that IO.executeForked is the method version of this
function (e.g. io.executeForked() == IO.fork(this)
).
IO.of(() => fs.readFileSync(path))
.executeForked()
Also see IO.shift and IO.asyncBoundary.
is the task that will get executed asynchronously
is the Scheduler
used for triggering the async
boundary, or if not provided it will default to the
scheduler passed on evaluation in IO.run
Converts any strict Future
value into an IO.
Note that this builder does not suspend any side effects, since
the given parameter is strict (and not a function) and because
Future
has strict behavior.
See IO.deferFuture for an alternative that evaluates lazy thunks that produce future results.
Returns a IO
reference that will signal the result of the
given Try<A>
reference upon evaluation.
Nondeterministically gather results from the given collection of tasks, returning a task that will signal the same type of collection of results once all tasks are finished.
This function is the nondeterministic analogue of sequence
and should behave identically to sequence
so long as there is
no interaction between the effects being gathered. However,
unlike sequence
, which decides on a total order of effects,
the effects in a gather
are unordered with respect to each
other.
In other words gather
can execute IO
tasks in parallel,
whereas IO.sequence forces an execution order.
Although the effects are unordered, the order of results matches the order of the input sequence.
const io1 = IO.of(() => 1)
const io2 = IO.of(() => 2)
const io3 = IO.of(() => 3)
// Yields [1, 2, 3]
const all: IO<number[]> = IO.gather([f1, f2, f3])
Maps 2 IO
values by the mapping function, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.sequence operation and as such on cancellation or failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
// Yields Success(3)
IO.map2(fa1, fa2, (a, b) => a + b)
// Yields Failure, because the second arg is a Failure
IO.map2(fa1, IO.raise("error"),
(a, b) => a + b
)
This operation is the Applicative.map2
.
Maps 3 IO
values by the mapping function, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.sequence operation and as such on cancellation or failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
// Yields Success(6)
IO.map3(fa1, fa2, fa3, (a, b, c) => a + b + c)
// Yields Failure, because the second arg is a Failure
IO.map3(
fa1, fa2, IO.raise("error"),
(a, b, c) => a + b + c
)
Maps 4 IO
values by the mapping function, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.sequence operation and as such on cancellation or failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
// Yields Success(10)
IO.map4(fa1, fa2, fa3, fa4, (a, b, c, d) => a + b + c + d)
// Yields Failure, because the second arg is a Failure
IO.map4(
fa1, fa2, fa3, IO.raise("error"),
(a, b, c, d) => a + b + c + d
)
Maps 5 IO
values by the mapping function, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.sequence operation and as such on cancellation or failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
const fa5 = IO.of(() => 5)
// Yields Success(15)
IO.map5(fa1, fa2, fa3, fa4, fa5,
(a, b, c, d, e) => a + b + c + d + e
)
// Yields Failure, because the second arg is a Failure
IO.map5(
fa1, fa2, fa3, fa4, IO.raise("error"),
(a, b, c, d, e) => a + b + c + d + e
)
Maps 6 IO
values by the mapping function, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.sequence operation and as such on cancellation or failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
const fa5 = IO.of(() => 5)
const fa6 = IO.of(() => 6)
// Yields Success(21)
IO.map6(
fa1, fa2, fa3, fa4, fa5, fa6,
(a, b, c, d, e, f) => a + b + c + d + e + f
)
// Yields Failure, because the second arg is a Failure
IO.map6(
fa1, fa2, fa3, fa4, fa5, IO.raise("error"),
(a, b, c, d, e, f) => a + b + c + d + e + f
)
Returns an IO
that on execution is always successful,
emitting the given strict value.
Promote a thunk
function to a Coeval
that is memoized on the
first evaluation, the result being then available on subsequent
evaluations.
Note this is equivalent with:
IO.always(thunk).memoize()
Maps 2 IO
values evaluated nondeterministically, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.gather operation. As such
the IO
operations are potentially executed in parallel
(if the operations are asynchronous) and on cancellation or
failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
// Yields Success(3)
IO.parMap2(fa1, fa2, (a, b) => a + b)
// Yields Failure, because the second arg is a Failure
IO.parMap2(fa1, IO.raise("error"),
(a, b) => a + b
)
Maps 3 IO
values evaluated nondeterministically, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.gather operation. As such
the IO
operations are potentially executed in parallel
(if the operations are asynchronous) and on cancellation or
failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
// Yields Success(6)
IO.parMap3(fa1, fa2, fa3, (a, b, c) => a + b + c)
// Yields Failure, because the second arg is a Failure
IO.parMap3(
fa1, fa2, IO.raise("error"),
(a, b, c) => a + b + c
)
Maps 4 IO
values evaluated nondeterministically, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.gather operation. As such
the IO
operations are potentially executed in parallel
(if the operations are asynchronous) and on cancellation or
failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
// Yields Success(10)
IO.parMap4(fa1, fa2, fa3, fa4, (a, b, c, d) => a + b + c + d)
// Yields Failure, because the second arg is a Failure
IO.parMap4(
fa1, fa2, fa3, IO.raise("error"),
(a, b, c, d) => a + b + c + d
)
Maps 5 IO
values evaluated nondeterministically, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.gather operation. As such
the IO
operations are potentially executed in parallel
(if the operations are asynchronous) and on cancellation or
failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
const fa5 = IO.of(() => 5)
// Yields Success(15)
IO.parMap5(fa1, fa2, fa3, fa4, fa5,
(a, b, c, d, e) => a + b + c + d + e
)
// Yields Failure, because the second arg is a Failure
IO.parMap5(
fa1, fa2, fa3, fa4, IO.raise("error"),
(a, b, c, d, e) => a + b + c + d + e
)
Maps 6 IO
values evaluated nondeterministically, returning a new
IO
reference that completes with the result of mapping that
function to the successful values of the futures, or in failure in
case either of them fails.
This is a specialized IO.gather operation. As such
the IO
operations are potentially executed in parallel
(if the operations are asynchronous) and on cancellation or
failure all pending tasks get cancelled.
const fa1 = IO.of(() => 1)
const fa2 = IO.of(() => 2)
const fa3 = IO.of(() => 3)
const fa4 = IO.of(() => 4)
const fa5 = IO.of(() => 5)
const fa6 = IO.of(() => 6)
// Yields Success(21)
IO.parMap6(
fa1, fa2, fa3, fa4, fa5, fa6,
(a, b, c, d, e, f) => a + b + c + d + e + f
)
// Yields Failure, because the second arg is a Failure
IO.parMap6(
fa1, fa2, fa3, fa4, fa5, IO.raise("error"),
(a, b, c, d, e, f) => a + b + c + d + e + f
)
Returns an IO
that on execution is always finishing in error
emitting the specified exception.
Transforms a list of IO
values into an IO
of a list,
ordering both results and side effects.
This operation would be the equivalent of Promise.all
or of
Future.sequence
, however because of the laziness of IO
the given values are processed in order.
Sequencing means that on evaluation the tasks won't get processed in parallel. If parallelism is desired, see IO.gather.
Sample:
const io1 = IO.of(() => 1)
const io2 = IO.of(() => 2)
const io3 = IO.of(() => 3)
// Yields [1, 2, 3]
const all: IO<number[]> = IO.sequence([f1, f2, f3])
Shifts the bind continuation of the IO
onto the specified
scheduler, for triggering asynchronous execution.
Asynchronous actions cannot be shifted, since they are scheduled
rather than run. Also, no effort is made to re-shift synchronous
actions which follow asynchronous actions within a bind chain;
those actions will remain on the continuation call stack inherited
from their preceding async action. The only computations which
are shifted are those which are defined as synchronous actions and
are contiguous in the bind chain following the shift
.
For example this sample forces an asynchronous boundary
(which usually means that the continuation is scheduled
for asynchronous execution with setTimeout
) before the
file will be read synchronously:
IO.shift().flatMap(_ => fs.readFileSync(path))
On the other hand in this example the asynchronous boundary is inserted after the file has been read:
IO.of(() => fs.readFileSync(path)).flatMap(content =>
IO.shift().map(_ => content))
The definition of IO.async is literally:
source.flatMap(a => IO.shift(ec).map(_ => a))
And the definition of IO.fork is:
IO.shift(ec).flatMap(_ => source)
is the Scheduler
used for triggering the async
boundary, or if not provided it will default to the
scheduler passed on evaluation in IO.run
Keeps calling f
until a Right(b)
is returned.
Based on Phil Freeman's Stack Safety for Free.
Described in FlatMap.tailRecM
.
Shorthand for now(undefined as void)
, always returning
the same reference as optimization.
Generated using TypeDoc
IO
represents a specification for a possibly lazy or asynchronous computation, which when executed will produce anA
as a result, along with possible side-effects.Compared with Funfix's Future (see funfix-exec) or JavaScript's Promise,
IO
does not represent a running computation or a value detached from time, asIO
does not execute anything when working with its builders or operators and it does not submit any work into the Scheduler or any run-loop for execution, the execution eventually taking place only after IO.run is called and not before that.In order to understand
IO
, here's the design space:A
() => A
(Try<A> => void) => void
() => ((Try<A> => void) => void)
Future<A>
/Promise
JavaScript is a language (and runtime) that's strict by default, meaning that expressions are evaluated immediately instead of being evaluated on a by-need basis, like in Haskell.
So a value
A
is said to be strict. To turn anA
value into a lazy value, you turn that expression into a parameterless function of type() => A
, also called a "thunk".A Future is a value that's produced by an asynchronous process, but it is said to have strict behavior, meaning that when you receive a
Future
reference, whatever process that's supposed to complete theFuture
has probably started already. This goes for JavaScript's Promise as well.But there are cases where we don't want strict values, but lazily evaluated ones. In some cases we want functions, or
Future
-generators. Because we might want better handling of parallelism, or we might want to suspend side effects. As without suspending side effects we don't have referential transparency, which really helps with reasoning about the code, being the essence of functional programming.This
IO
type is thus the complement toFuture
, a lazy, lawful monadic type that can describe any side effectful action, including asynchronous ones, also capable of suspending side effects.Getting Started
To build an
IO
from a parameterless function returning a value (a thunk), we can useIO.of
:const hello = IO.of(() => "Hello ") const world = IO.of(() => "World!")
Nothing gets executed yet, as
IO
is lazy, nothing executes until you trigger run on it.To combine
IO
values we can usemap
andflatMap
, which describe sequencing and this time is in a very real sense because of the laziness involved:const sayHello = hello .flatMap(h => world.map(w => h + w)) .map(console.info)
This
IO
reference will trigger a side effect on evaluation, but not yet. To make the above print its message:const f: Future<void> = sayHello.run() //=> Hello World!
The returned type is a Future, a value that can be completed already or might be completed at some point in the future, once the running asynchronous process finishes. It's the equivalent of JavaScript's
Promise
, only better and cancelable, see next topic.Laziness
The fact that
IO
is lazy, whereasFuture
andPromise
are not has real consequences. For example withIO
you can do this:function retryOnFailure<A>(times: number, io: IO<A>): IO<A> { return source.recoverWith(err => { // No more retries left? Re-throw error: if (times <= 0) return IO.raise(err) // Recursive call, yes we can! return retryOnFailure(times - 1, io) // Adding 500 ms delay for good measure .delayExecution(500) }) }
Future
being a strict value-wannabe means that the actual value gets "memoized" (means cached), howeverIO
is basically a function that can be repeated for as many times as you want.IO
can also do memoization of course:The difference between this and just calling
run()
is thatmemoize()
still returns anIO
and the actual memoization happens on the firstrun()
(with idempotency guarantees of course).But here's something else that
Future
or your favoritePromise
-like data type cannot do:This keeps repeating the computation for as long as the result is a failure and caches it only on success. Yes we can!
Parallelism
Because of laziness, invoking IO.sequence will not work like it does for
Future.sequence
orPromise.all
, the givenIO
values being evaluated one after another, in sequence, not in parallel. If you want parallelism, then you need to use IO.gather and thus be explicit about it.This is great because it gives you the possibility of fine tuning the execution. For example, say you want to execute things in parallel, but with a maximum limit of 30 tasks being executed in parallel. One way of doing that is to process your list in batches.
This sample assumes you have lodash installed, for manipulating our array:
import * as _ from "lodash" import { IO } from "funfix" // Some array of IOs, you come up with something good :-) const list: IO<string>[] = ??? // Split our list in chunks of 30 items per chunk, // this being the maximum parallelism allowed const chunks = _.chunks(list, 30) // Specify that each batch should process stuff in parallel const batchedIOs = _.map(chunks, chunk => IO.gather(chunk)) // Sequence the batches const allBatches = IO.sequence(batchedIOs) // Flatten the result, within the context of IO const all: IO<string[]> = allBatches.map(batches => _.flatten(batches))
Note that the built
IO
reference is just a specification at this point, or you can view it as a function, as nothing has executed yet, you need to call .run explicitly.Cancellation
The logic described by an
IO
task could be cancelable, depending on how theIO
gets built. This is where theIO
-Future
symbiosis comes into play.Futures can also be canceled, in case the described computation can be canceled. When describing
IO
tasks withIO.of
nothing can be cancelled, since there's nothing about a plain function that you can cancel, but, we can build cancelable tasks with IO.async:import { Cancelable, Success, IO } from "funfix" const delayedHello = IO.async((scheduler, callback) => { const task = scheduler.scheduleOnce(1000, () => { console.info("Delayed Hello!") // Signaling successful completion // ("undefined" inhabits type "void") callback(Success(undefined)) }) return Cancelable.of(() => { console.info("Cancelling!") task.cancel() }) })
The sample above prints a message with a delay, where the delay itself is scheduled with the injected
Scheduler
. TheScheduler
is in fact an optional parameter to IO.run and if one isn't explicitly provided, thenScheduler.global
is assumed.This action can be cancelled, because it specifies cancellation logic. If we wouldn't return an explicit
Cancelable
there, then cancellation wouldn't work. But for thisIO
reference it does:// Triggering execution, which sends a task to execute by means // of JavaScript's setTimeout (under the hood): const f: Future<void> = delayedHello.run() // If we change our mind before the timespan has passed: f.cancel()
Also, given an
IO
task, we can specify actions that need to be triggered in case of cancellation:const io = IO.of(() => console.info("Hello!")) .executeForked() io.doOnCancel(IO.of(() => { console.info("A cancellation attempt was made!") }) const f: Future<void> = io.run() // Note that in this case cancelling the resulting Future // will not stop the actual execution, since it doesn't know // how, but it will trigger our on-cancel callback: f.cancel() //=> A cancellation attempt was made!
Note on the ExecutionModel
IO
is conservative in how it introduces async boundaries. Transformations likemap
andflatMap
for example will default to being executed on the current call stack on which the asynchronous computation was started. But one shouldn't make assumptions about how things will end up executed, as ultimately it is the implementation's job to decide on the best execution model. All you are guaranteed is asynchronous execution after executingrun
.Currently the default
ExecutionModel
specifies batched execution by default andIO
in its evaluation respects the injectedExecutionModel
. If you want a different behavior, you need to execute theIO
reference with a different scheduler.In order to configure a different execution model, this config can be injected by means of a custom scheduler:
import { Scheduler, ExecutionModel } from "funfix" const ec = Scheduler.global.get() .withExecutionModel(ExecutionModel.alwaysAsync()) // ... io.run(ec)
Or you can configure an
IO
reference to execute with a certain execution model that overrides the configuration of the injected scheduler, by means of IO.executeWithModel:io.executeWithModel(ExecutionModel.batched(256))
Versus Eval
For dealing with lazy evaluation, the other alternative is the Eval data type.
Differences between
Eval
andIO
:IO
is capable of describing asynchronous computations as wellIO
is capable of error handling (it implementsMonadError
), whereasEval
does not provide error handling capabilities, being meant to be used for pure expressions (it implementsComonad
, which is incompatible withMonadError
)IO
to produce a value immediately, since we cannot block threads on top of JavaScript enginesSo if you need error handling capabilities (i.e.
MonadError<Throwable, ?>
), or if you need to describe asynchronous processes, thenIO
is for you. Eval is a simpler data type with the sole purpose of controlling the evaluation of expressions (i.e. strict versus lazy).Credits
This type is inspired by
cats.effect.IO
from Typelevel Cats, bymonix.eval.Task
from Monix, byscalaz.effect.IO
from Scalaz, which are all inspired by Haskell'sIO
data type.