Readiness: Implemented
Build an app
Effects
@uniflowed/effect puts the failure in the type: an Effect<A, E, R> says what
it produces, how it can fail, and what it needs. It is Effect-TS's model, minus
everything that model gets from higher-kinded types — which Flow does not have,
and which this package does not pretend to.
What you will be able to do: write an Effect whose type says what it
produces, how it fails and what it needs; compose, recover, retry and run work
concurrently; and know what Flow cannot express that Effect-TS can.
What you need first: a project — Your first project. Effect-TS is not assumed; Coming from Effect-TS, near the end, is where the two part company.
This page is the walk-through. The Effect reference is the contract: every runner, fork, schedule, layer and stream, and the reason behind each absence. Where a topic below ends in a link, the rest of it is there and not repeated here.
The three channels
An Effect<A, E, R> is a description of work that has not run: A is what it
produces, E is the failure it names, and R is the services it needs. It is
inert until you run it.
import { runPromise, runSync, succeed } from "@uniflowed/effect";
runSync(succeed(3)); // 3
runSync returns the value or raises and runPromise is the asynchronous form;
Running lists all five runners, and why runSync
refuses an effect that has to wait rather than handing back a promise.
Failure is three things, not one — a typed fail, a die that is a bug, and
an interruption that is a decision somebody already took — and only the first
is recovered or retried.
Failure is not the same as a defect
has the table.
Failures you expect
A throw inside sync, promise or call becomes a defect, not a typed
failure. trySync and tryPromise name a failure through an explicit error
mapper:
import { tryPromise, trySync } from "@uniflowed/effect";
const fetched = tryPromise({
try: () => fetch(url).then((response) => response.json()),
catch: (cause) => ({ kind: "network", cause }),
});
const decoded = trySync({
try: () => JSON.parse(source),
catch: () => ({ kind: "invalid-json", message: "Enter a JSON document." }),
});
That is deliberate: a catch (e) that turns every throw into a typed error is
how a null-pointer bug ends up being retried three times and reported to a user
as "the network is busy".
The mapper is handed the original thrown value, and a mapper that throws is
itself a defect. Both callbacks run only when the effect executes, and
trySync works under runSync as well as runPromise. Compose it with
ensuring when the operation owns cleanup, such as rolling back a synchronous
database transaction: the cleanup runs before the original typed failure is
returned to the caller.
Composing
Two ways, and the generator is the one to reach for:
import { effect, succeed } from "@uniflowed/effect";
const program = effect(function* () {
const first = yield* succeed(2);
const second = yield* succeed(3);
return first * second;
});
yield* is the typed form — first is a number there, where a bare yield
would hand back mixed. The failure channels of everything yielded are unioned
into the program's.
Or combinators, all of them data-first, two arguments: map(self, f),
flatMap(self, next), tap, zip, as, mapError, delay. There is no
pipe and no self.pipe(...), because there is no higher-kinded encoding for
one pipe to be generic over.
Recovering
catchAll(parse(input), (problem) => succeed(fallbackFor(problem.field)));
catchTag(request, "timeout", () => succeed(cached));
either(request); // { ok: true, value } | { ok: false, error }
exit(request); // the Exit, defects and interruption included
orDie(request); // a typed failure becomes a defect
filterOrFail(request, ok, (value) => ({ kind: "rejected", value }));
timeout(request, 2000); // adds TimeoutError to the error channel
orElse is the other recovery that acts on a typed failure.
catchTag reads kind first and then tag, so it works with uf's own
discriminant and with an error ported from Effect-TS. It does not narrow:
recover still sees the whole E, because Flow cannot narrow a type variable by
a string compared at run time. That is a real loss against Effect-TS, and the
package says so rather than shipping a type that looks like it narrows.
Concurrency
all(effects, { concurrency: 4 });
forEach(items, (item) => work(item), { concurrency: "unbounded" });
race([fromCache, fromNetwork]);
all fails fast on the first failure in completion order, then interrupts its
siblings and waits for them — the difference between a combinator and a leak.
concurrency is a number or "unbounded"; Effect's "inherit" is absent
because there is no enclosing limit recorded to inherit, and an option that
silently means something else is worse than one that is not offered.
race waits for a success, not for the first thing to settle. A failing
entrant drops out of the race rather than ending it; if every entrant fails, the
result carries all of their causes. The cost is stated: a loser is interrupted
and not awaited, so a loser with side effects has to survive running a little
past the point race returned.
For work you start yourself, fork hands back a fiber that join waits for and
interrupt stops. A forked child dies with the fiber that forked it;
forkDaemon outlives it and forkScoped lives as long as the enclosing scope.
Fibers, and what a fiber owns
is the whole rule, and
What two fibers can share covers
Ref, Deferred, Semaphore, Queue and PubSub.
Resources
const handle = acquireRelease(
sync(() => open(path)),
(file) => sync(() => file.close()),
);
runPromise(scoped(flatMap(handle, read)));
acquireRelease needs a scope, and scoped is what provides one. Without it
the effect dies with a message telling you to wrap it — a skipped release is
a leak that only shows up in production, so it is not something to be silently
tolerant of. ensuring is the unconditional form without a scope;
Resources has the guarantees.
Retrying
A Schedule<In> is plain data — a state machine, still written as a literal:
retry(request, { kind: "exponential", baseMillis: 100, factorPercent: 200 });
retry(request, { kind: "intersect", left: { kind: "recurs", times: 5 }, right: { kind: "upTo", millis: 10_000 } });
factorPercent is an integer percentage rather than a float, because 1.5
written as 150 survives a round trip through a JSON config file without
becoming 1.4999999999999998 — the same reason the whole type is data rather
than a builder.
retry counts retries, so { kind: "recurs", times: 2 } runs the effect three
times — measured, not assumed. It retries a typed failure only: a defect is a
bug, and running a bug again is not a strategy. repeat takes the same
schedules and runs again on success, which is how a poll is written.
Retrying has every schedule, scheduleStep for
testing a policy without a clock, and the while/until condition a call site
can add.
Services
const Clock = tag<{ readonly now: () => number }>("Clock");
const program = flatMap(Clock, (clock) => succeed(clock.now()));
runPromise(provideService(program, Clock, { now: () => Date.now() }));
runPromise(provide(program, layerSucceed(Clock, { now: () => 42 })));
A Tag is itself an Effect, so yield* Clock works inside a generator.
provideService satisfies one requirement with a value; provide applies a
layer.
This is the part marked experimental: Flow has no type-level set
difference, so the provided service is solved out of a union and can silently
stay in R. A service that is missing at run time is a defect, not a
failure — the type was meant to have made it impossible.
Services has the typing, the layer combinators,
one build per provide, and managedRuntime for an application that builds
its layers once.
Coming from Effect-TS
| Effect-TS | Here | Why |
|---|---|---|
self.pipe(map(f)) | map(self, f) | No higher-kinded types, so no generic pipe, no typeclasses, no Do |
| Requirement subtraction | Solved from a union | Flow has no type-level set difference |
catchTag narrows on _tag | catchTag reads kind then tag, and does not narrow | Flow cannot narrow a type variable by a run-time string |
| Four forks | fork, forkDaemon and forkScoped | forkIn needs a Scope that is a value, and here it is a phantom |
Schedule<Out, In, R> | Schedule<In>, output always a number | A union arm cannot constrain a type parameter it does not mention |
race takes the first to settle | race takes the first to succeed | "Whichever settles first" is not what a caller wants when the fastest answer is an error |
Heterogeneous all keeping tuple types | One array type in, one out | No variadic tuple inference to build it from |
"inherit" concurrency | Absent | Nothing tracks an enclosing limit |
Context module | tag, provideService and provide | The context they thread is internal |
What is not implemented at all — STM, Channel, Sink, a scheduler of its
own, tracing — is listed once, in
What is not here.
The package has no dependencies at all, and every snippet on this page was
run before it was published. npm/effect/effect.test.js is where the
behaviour is pinned — uf test npm/effect/effect.test.js.
Where to go next
GraphQL and Relay is next: Relay artifacts, typed components and request-scoped preloads. The Effect reference has every export.
Edit this pagedocs/app/guide/effect/$page.mdx