Effect<O, T, E> (./types.ts) is a Pure thunk yielding
Result<T, E> or a Do node: the failure is part of the representation, not a
wrapper around it. One type, one module —
./module.f.mjs holds the representation, its interpreters
and the combinators alike.
It was two of each. A payload-generic RawEffect<O, T> sat underneath, with
Effect an alias for RawEffect<O, Result<T, E>>, and the division was called
composition against representation. But an operation must return a Result,
so the payload a runner is generic over is always a Result — the second name
described no reachable case, while giving every combinator a Result-blind twin
that would run the next link after a failed one. Nothing "genuinely cannot
fail": a List cell, a Program's exit code, and an MCP tool result were each
named here as such, and all three carry channels now.
The combinators lived in an io/ subdirectory while that lasted, because their
step collided by name with the Result-blind one. It was never an IO layer —
the name came from its being the fallible half — and with the twins gone there
was neither a collision nor a second module to justify.
The migration is recorded in
./todo/io-effect-migration.md and is
complete: every operation declares a Result return, every runner answers with
one, and every consumer composes with step / catchStep / resultStep
instead of stating a policy per site.
throw in FunctionalScript is a panic, and the language offers no
try/catch, so the effect system itself has to carry the recovery path. It
once did not: the step that composed effects did not understand Result, and
a continuation that ignored its argument ran on the success path whether or not
the previous effect failed.
const a = writeFile(...)
const b = step(a, () => console('written'))writeFile could return an error and 'written' was still printed. The code
looked like an ordinary sequence, but it meant "run the next effect
regardless". Every caller had to remember to inspect each Result by hand;
missing one silently changed control flow. That spelling no longer exists — the
Result-blind combinators are gone, and the one remaining way to say "whatever
it answered" is resultStep, which hands the answer over.
With the error-aware step, that same line means what it looks like:
console('written') runs only on ok, and an error propagates on its own. That
gives FunctionalScript the default error-propagation path other languages get
from exceptions or Rust's ?, without giving it exceptions.
Three branch-aware operations, in ./module.f.mjs:
step— continue onok, propagate the error. The normal path.catchStep— continue onerror, preserve the success value. The error path.resultStep— continue with the completeResult. Both paths, explicitly.
The union rules follow okThen in
../types/result/module.f.mjs: error
types are unioned, not unified, so neither side is pre-widened and a branch
that is passed through stays the very tuple it arrived as. step unions the
error channel and replaces the success type; catchStep mirrors it, unioning
the success channel and replacing the error type; resultStep consumes both
branches and replaces both. types.ts pins each of those signatures at a
concrete instantiation, so a "simplification" that unified an error channel
fails there rather than at some future call site.
The composition API and the representation share one module: pure, do_,
match, partialMatch and runPure sit beside step and the rest in
./module.f.mjs. They were split while that module also
exported Result-blind twins of step, mapStep, history, historyStep,
foldStep and forEachStep, since the names collided. The twins are gone, and
so is the split.
resultStep is the former raw step: a continuation that takes the whole
Result<T, E> and returns an effect. step and catchStep are written in
terms of it — each is this function with a tag test in front — so the
both-branches case is not a third variant beside them but the thing they are
derived from.
That direction used to be reversed. The general function lived in the
representation module with an opaque payload, and resultStep was it
re-exported under a narrower type; the narrower type was doing all the work,
because the payload was always a Result anyway. Now the function lives where
its type is honest, and nothing above it can spell the both-branches case by
accident.
resultMapStep is its trailing-projection form, as mapStep is step's.
Reach for it where a projection decides the outcome — turning any answer into
a fixed one, replacing a channel wholesale — and for mapStep where only the
value is being transformed. resultMapStep is also the honest spelling for a
site that means to discard an error: the discarding is written down, in a
function that says it takes both branches.
finallyStep is declined on the principle that a derivable form earns a name by
being canonical vocabulary, and that one has not shown it is.
The two lifts enter the layer from a plain value — the other way in is an
operation, which now declares a Result return of its own. They are not
spelled ok / error: those names are
fjs/types/result's, and a consumer that both builds bare Results and lifts
them — which is every consumer during the migration — would have to alias one
pair at each import. pure is not free to shadow either; it is the raw lift,
and a module that uses both wants them distinguishable.
step's two error types union (E | F) rather than unifying, and an error
is handed back as the very tuple it arrived as rather than rebuilt to retag it
into a wider type. That is what lets adjacent links in one chain fail in
different ways, and it follows okThen (fjs/types/result/module.f.mjs), the
pure sibling of this bind.
This used to route through an okStep exported by the representation module —
an adapter whose only caller was step's own body, one indirection away. It is
written here now, and that module has one export fewer.
NotImplemented says the runner cannot dispatch an operation and has not
started it. It is ordinary recoverable effect data, not a fatal runner
condition: the program gets control back and owns the policy — recover, choose a
fallback operation, or panic itself.
By command name only. An operation's payload may hold functions —
createServer's listener, sandbox's thunk, test's body — so carrying it
would break the serializability claim. types.ts pins that claim as a
type-level assert against the JSON data model rather than leaving it as a
comment, so a later payload field fails to compile.
Not a security boundary. A runner keeps its authority over execution through
a separate, out-of-band mechanism: it may interrupt or terminate a program that
is malicious, over budget, or violating host policy, and nothing in the error
channel obliges it to hand control back. The two mechanisms must not be
conflated in either direction — a capability the runner merely lacks is answered
with NotImplemented, never by killing the program, and a refusal to continue
is an interruption, never dressed up as NotImplemented.
Not every consumer is ready to compose. Two named policies exist so that a site which has not adopted the layer still has to say what it does with a failure rather than discard it:
unwrapStep(here) — panic on the error branch. It belongs where the caller genuinely has no answer: a build tool that cannot read its own sources, a reporter that cannot reach stdout. It is one greppable name rather than anunwrapburied in a continuation, so a site that has not chosen a real policy is exactly a site this name marks. The library has none left; the remaining calls are in test harnesses, where a missing fixture is fatal.exitStep/errorMessage(./node/module.f.mjs) — aNodeProgram's exit-code policy: report the failure onstderrand exit1.
Neither is composition, and neither should grow: a consumer that can do
something better with a failure wants catchStep or resultStep.
This section used to list notImplemented, historyStep, foldStep and
forEachStep as things the layer had not needed yet, to be shaped by their
first consumer. All four exist now, and the list outlived them; what follows is
what is still absent, and why.
- No
finallyStep. It isresultStepplus a policy. A derivable form earns a name by being canonical vocabulary, and no repeated policy has shown up to make this one canonical. - No
defer.(() => Effect<O, T, E>) => Effect<O, T, E>cannot be written: composition reads thePure/Dotag before anything runs, and the union has no third case meaning "not yet decided". That is the representation, not a gap in this API — a caller who needs to name a composition without performing it keeps the ingredients and defers the step itself. - No second,
Result-blind API. There was one, and it is gone: see the top of this file for why one effect type means one set of combinators.