Behavior Modes
This document is the single source of truth for the error-handling behavior of the whole library: unified terminology plus a per-module list of what every API does with throws, failure inputs, and empty inputs. Full signatures and JSDoc live in the source files; this document only settles behavioral conclusions and links back to them. The channel policy is checked by the consistency guard in
src/tests/hardening/behavior-policy.spec.ts: a new public export that is not registered inbehavior-matrix.ts, or a probe that contradicts the declared behavior, failsnpm test. See also the module map in AGENTS.md; each module’s API index lives in its ownsrc/<module>/README.md, and ADR-level rationale lives insrc/types/README.md.
1. Reading guide: the exact scope of exception-free
Section titled “1. Reading guide: the exact scope of exception-free”exception-free at the top of README and SPEC means the main flow carries errors as values: success and failure both travel explicitly in the type system as plain objects (IResultOfT / IOption), so callers know a function can fail without a try/catch.
It does not mean “the library never throws”. The following three throw channels are part of the design:
- Callback propagation: some operators do not capture throws from the function they are given and let them bubble up unchanged (see the per-module tables in §4).
- Escape hatches: the
unwrap/expect/unsafe*/orThrowfamily throws deliberately on contract violations (see the escape-hatch table in §4.2). - The async rejection channel: a
rejecton the outerPromiseitself always stays a rejection; it is never converted intoErr(see §4.6). Only thereliabilitylayer promises never to reject.
To decide whether an API throws, consult the tables in §4 — do not guess from the name (for example, synchronous map captures but bind propagates).
2. Glossary
Section titled “2. Glossary”| Term | Definition |
|---|---|
| NeverThrow | The function body contains no throw; an internal try/catch turns throws into Err / None / a default value. |
| CatchToErr | A synchronous throw from a callback is captured and turned into Err (on the Option side, into None / a default value). Whether a throw from errorFn itself is captured a second time is noted separately in each table. |
| Propagate | A throw from a callback, or a rejected Promise it returns, is not captured and is handed to the caller as-is. |
| Panic | On misuse, throw new TypeError / new Error marks a contract violation (Rust-style unwrap semantics). |
| ThrowRaw | The carried value is thrown as-is (throw r.error), without wrapping it in a TypeError. |
| ThrowTyped | A typed error is thrown: either the value is thrown directly when E extends Error, or it is mapped through errorFn first. |
| FailFirst | Return on the first Err / None without processing the remaining inputs. Note: FailFast is a synonym; this document consistently uses FailFirst. |
| Accumulate | Run every input and collect all errors into an Err(E[]) before returning. |
| NeverRejects | The returned Promise never enters the rejected state; throws and rejections are all collapsed into Err. Only the reliability layer gives this guarantee. |
| Lazy / Eager | A lazy result returns a thunk and does nothing until run() is called; an eager one starts the chain on the call and returns an in-flight Promise. |
3. Overview matrix
Section titled “3. Overview matrix”| Module | Callback throws | Failure inputs | Empty inputs | Execution timing |
|---|---|---|---|---|
factories |
No callbacks to construct; fromThrowable / tryCatch / fromPromise / fromSafePromise capture to values (fromSafePromise normalizes a non-Error rejection value to Error when no errorFn is given) |
Not applicable | Not applicable | Synchronous; fromPromise returns a Promise |
operators |
Split: the map family captures (choose is the exception: it propagates and skips Err), the bind family propagates, escape hatches throw deliberately |
Mostly FailFirst; separate accumulates into partitions |
Not applicable | Synchronous |
combine |
Pure value combination, no callbacks | combine / all fail fast, combineWithAllErrors accumulates errors |
Ok([]) |
Synchronous |
composition |
pipe / pipeAsync propagate (pipeAsync turns a synchronous step throw into a rejection and does not unwrap thenables); composeK / composeKAsync capture to values (the async variant also captures rejections); safeTry / safeTryAsync rethrow generator throws (the async variant throws TypeError on an invalid resolved value and rejects on its own throws) |
FailFirst (through bind) |
composeK / composeKAsync panic at construction time with zero functions |
Synchronous composition; fromSafeTryAsync is lazy |
adapters |
switchFn captures, tee propagates, liftMap delegates to map’s capturing |
Propagate / convert; there is no short-circuit concept | Not applicable | Synchronous |
primitives |
cond / reduce propagate; lift is dual-state (captures with an errorFn, propagates without one) |
sequence / reduce fail fast |
Not applicable | Synchronous; sequenceAsyncResult is lazy (construction only grabs the run reference, and a synchronous throw inside run() also propagates) |
option |
Everything except match captures to None / a default value |
Short-circuits on None |
all([]) is rejected at the type level (unlike combine) |
Synchronous, zero panic APIs |
promise-result |
Split: the map / tap / bimapAsync / asyncBindThrough / bindThroughAsync families capture to values, the bind / match / catchErrAsync families propagate |
FailFirst / accumulate (as in combine) |
Ok([]); an outer rejection stays a rejection |
Eager |
promise-option |
Everything except the lifting family captures to None / a default value; the lifting family turns synchronous throws into None but lets rejections propagate (asyncMapOption / asyncBindOption / asyncTapOption / asyncOrElseOption / tapErrAsyncOption) |
Short-circuits on None |
No combination APIs | Eager |
async-result |
Everything except the propagating mapAsync / mapOrElse / match / exists / unwrapOrElse / ap captures to values (ap is a lazy middleware) |
FailFirst / accumulate |
Ok([]) |
Lazy: middleware does not run, terminals trigger it |
async-option |
Everything except the propagating match / mapOrElse / unwrapOrElse / exists / zipWith captures to values (mapAsync is the opposite of the async-result side: it captures here, by declaration) |
Short-circuits on None (all actually runs every carrier; see §5, item 3) |
Some([]); zipWith returns None on missing arguments |
Lazy, triggered by terminals |
reliability |
Always captures to values and never rejects | race settles on the first Ok; any / allSettled run every input |
race([]) becomes Err; the others return an empty success |
retry / timeoutEager are eager, the rest are lazy |
observability |
observe / installObserver / tapErrContext swallow observer errors without changing the outcome; ctx is AsyncLocalStorage breadcrumbs |
Propagate | Not applicable | Synchronous |
4. Per-module reference
Section titled “4. Per-module reference”4.1 Factories (src/factories/)
Section titled “4.1 Factories (src/factories/)”| Exports | Behavior | Source |
|---|---|---|
ok / err |
Never throw, plain object literals | ok.ts, err.ts |
fromPredicate |
Never throws itself; a throw from the supplied predicate propagates |
fromPredicate.ts |
fromThrowable / tryCatch |
Capture to values; a second throw from errorFn is captured by the inner handler as well |
fromThrowable.ts, tryCatch.ts |
fromSafePromise |
Captures to values; without an errorFn, a non-Error rejection value is normalized to new Error(String(e)), and a throw from errorFn is also collapsed; E defaults to Error |
fromSafePromise.ts |
fromPromise / tryCatchAsync / asyncOk / asyncErr |
Capture to values; a Promise rejection becomes Err instead of staying a rejection |
fromPromise.ts, tryCatchAsync.ts |
4.2 Synchronous operators (src/operators/)
Section titled “4.2 Synchronous operators (src/operators/)”Transform and recover: callback behavior is split — use the table.
| Exports | Callback throws | Failure inputs | Source |
|---|---|---|---|
map / orElse / tap / tapErr / bimap / filterOrElse / traverseArray / ap / andTee / orTee / andThrough |
Capture to values (orElse / bimap / filterOrElse also collapse a second throw from errorFn; a second throw from the errorFn of the map / tap family propagates) |
Fail fast (FailFirst) |
map.ts, orElse.ts, tap.ts, bimap.ts, filterOrElse.ts, traverseArray.ts, ap.ts |
bind / mapErr / match / catchErr / unwrapOrElse / mapOr / mapOrElse / exists |
Propagate directly, no capturing | Fail fast, or dispatch per branch; mapOr / mapOrElse take the default branch on failure |
bind.ts, mapErr.ts, match.ts, unwrapOrElse.ts |
choose |
Propagates directly without capturing; skips Err and keeps going (neither fail-fast nor accumulation) |
Same as the left cell | choose.ts |
flatten / and / or / swap / unwrapOr / contains / separate / unzip |
No callbacks, never throw; separate accumulates into partitions, the rest short-circuit or project |
Same as the left cell | combine.ts, separate.ts |
Terminal escape hatches: do not mix the four throw modes.
| Exports | Behavior | When to choose it | Source |
|---|---|---|---|
unwrap / expect / unwrapErr / expectErr |
Contract panic: throw new TypeError on violation, with throwingFn available to customize the thrown class |
Debugging and tests, where a stack trace and an explicit misuse signal matter | unwrap.ts, expect.ts, unwrapErr.ts, expectErr.ts |
unsafeUnwrap / unsafeUnwrapErr |
Throw raw: throw r.error / throw r.value, wrappers omitted |
Escape hatch: hand an error value you already know about to an outer try/catch |
unsafeUnwrap.ts, unsafeUnwrapErr.ts |
orThrow / orThrowWith |
Throw typed: orThrow requires E extends Error; orThrowWith maps through errorFn first |
Production code that must throw a typed Error subclass |
orThrow.ts |
4.3 Composition (src/combine/, src/composition/)
Section titled “4.3 Composition (src/combine/, src/composition/)”| Exports | Behavior | Source |
|---|---|---|
combine / all |
Fail fast (FailFirst), keeping the first Err; an empty array returns Ok([]); pure value combination that never throws |
combine.ts, all.ts |
combineWithAllErrors |
Accumulates errors; an empty array returns Ok([]); never throws |
combineWithAllErrors.ts |
pipe / pipeAsync |
Propagate directly: a synchronous throw in any pipe step bubbles out; pipeAsync turns a synchronous step throw into a rejection and does not unwrap thenables (a mixed chain passes the raw value on); it is unaware of Err |
pipe.ts, pipeAsync.ts |
composeK |
Captures to values: a synchronous step throw becomes Err and fails fast; a contract panic at construction time with zero functions |
composeK.ts |
composeKAsync |
Captures to values: synchronous throws and async rejections both become Err; a contract panic at construction time with zero functions |
composeKAsync.ts |
safeTry / fromSafeTry |
Rethrows generator throws; throws an Error when the generator succeeds without a value or yields twice |
safeTry.ts |
safeTryAsync / fromSafeTryAsync |
Rethrows generator throws (surfacing as rejections); throws a TypeError on an invalid resolved value; fromSafeTryAsync returns a lazy thunk |
safeTryAsync.ts |
4.4 Adapters and high-frequency primitives (src/adapters/, src/primitives/)
Section titled “4.4 Adapters and high-frequency primitives (src/adapters/, src/primitives/)”| Exports | Behavior | Source |
|---|---|---|
switchFn / switchFnAsync |
Capture to values (including a second collapse for errorFn) |
switchFn.ts |
tee / teeAsync |
Propagate directly (there is no failure state to convert) | tee.ts |
toOption / fromOption |
Never throw | toOption.ts, fromOption.ts |
liftMap |
Delegates to operators/map: a callback throw is captured as Err |
liftMap.ts |
cond / condErr / reduce |
Propagate directly; reduce additionally fails fast on a failed source or step |
cond.ts, reduce.ts |
sequence / partitionOption |
Never throw; sequence delegates to combine and fails fast |
sequence.ts |
lift |
Dual-state: with an errorFn it captures to values, without one it rethrows as-is. Note that E = never is only a type-level guarantee — the runtime can still throw |
lift.ts |
sequenceAsyncResult |
Lazy (construction only grabs the run reference); a synchronous throw and a runtime rejection inside run() both propagate as rejections, failing fast |
sequenceAsyncResult.ts |
4.5 Option (src/option/)
Section titled “4.5 Option (src/option/)”The whole module never throws (except match, which propagates), callback throws are always collapsed into None / a default value, and it short-circuits on None. The module deliberately has no panic APIs; use unwrapOr to extract.
| Exports | Behavior | Source |
|---|---|---|
ofSome / ofNone / flatten / contains / unwrapOr / okOr / transpose |
Never throw; no callbacks or callback-independent | ofSome.ts, unwrapOr.ts |
map / bind / filter / tap / orElse / okOrElse / zipWith |
Capture to values: a throwing callback returns None / the default value |
map.ts, bind.ts, zipWith.ts |
traverseArray / traverse |
Capture to values: a throwing callback or a throwing iterator next() returns None |
traverseArray.ts |
match |
Terminal that propagates | match.ts |
all |
Short-circuits on None; an empty tuple is rejected at the type level |
all.ts |
4.6 Eager async (src/promise-result/, src/promise-option/)
Section titled “4.6 Eager async (src/promise-result/, src/promise-option/)”Shared rule: eager execution that starts the chain on the call; a rejection of the outer Promise itself always stays a rejection and is never converted into Err / None.
| Exports | Callback behavior | Source |
|---|---|---|
promise-result’s map / mapErr / mapAsync / bimapAsync / asyncBindThrough / bindThroughAsync / the tap family / asyncMap / asyncTap |
Capture to values; for a synchronous mapper that returns a thenable, map additionally throws an Error pointing at mapAsync |
map.ts, mapAsync.ts, bimapAsync.ts, asyncBindThrough.ts, bindThroughAsync.ts |
promise-result’s bind / orElse / match / mapOrElse / unwrapOrElse / exists / filterOrElse / ap / the catchErrAsync family / asyncMatch |
Propagate directly; mapOrAsync is the special case that captures, returns the default value, and swallows observer exceptions |
bindAsync.ts, matchAsync.ts, catchErrAsync.ts, asyncMatch.ts |
promise-result’s combine / combineWithAllErrors |
The former fails fast, the latter accumulates errors; an empty array returns Ok([]); a rejection of any outer promise rejects the whole combination |
combine.ts |
promise-option’s map / bind / filter / exists / orElse / tap / the mapOr family |
Capture to values: a throwing callback or an async rejection is always collapsed into None / false / the default value |
mapAsyncOption.ts, bindAsyncOption.ts |
promise-option’s lifting family asyncMapOption / asyncBindOption / asyncTapOption / asyncOrElseOption / tapErrAsyncOption |
A synchronous throw becomes None; an async rejection propagates |
asyncMapOption.ts, asyncBindOption.ts, asyncOrElseOption.ts, tapErrAsyncOption.ts |
promise-option’s asyncMatchOption / matchAsyncOption / mapOrElseAsyncOption / unwrapOrElseAsyncOption |
Propagate directly (a synchronous throw becomes a rejection) | asyncMatchOption.ts |
This layer deliberately has no unwrap / expect / orThrow; extraction goes through the unwrapOr* family, whose async rejections travel on the outer rejection channel.
4.7 Lazy async (src/async-result/, src/async-option/)
Section titled “4.7 Lazy async (src/async-result/, src/async-option/)”Shared rule: middleware returns a new thunk and does not execute; terminals such as match / unwrap / unwrapOr return a Promise and call run() immediately.
| Exports | Behavior | Source |
|---|---|---|
from / fromResult / fromOption |
Pure lazy wrapping: no conversion, no execution | from.ts, fromOption.ts |
async-result’s fromPromise |
Captures to values: a thunk throw or rejection becomes Err, and a second throw from errorFn is collapsed too |
fromPromise.ts |
async-option’s fromPromise |
Captures to values: the signature is only (thunk) => Promise with no errorFn; a throw or rejection always becomes None |
fromPromise.ts |
async-result’s map / bind / bimap / mapErr / mapErrAsync / orElse / filterOrElse / tap / tapAsync / andThrough / catchErr |
Capture to values (including an async rejection from the inner carrier of bind / orElse and a failed recovery callback in catchErr); a misused thenable in map collapses into Err(Error) instead of throwing |
map.ts, bind.ts, catchErr.ts |
async-result’s mapAsync |
Propagates directly (the only counterexample in this layer); errorFn only remaps the rejection reason and still throws |
mapAsync.ts |
async-result’s match / exists / unwrapOrElse (terminals) plus ap (lazy middleware) / mapOrElse |
Propagate directly; mapOr is the special case that captures and returns the default value |
match.ts, exists.ts, ap.ts |
async-result’s unwrap / unwrapErr / expect / expectErr |
Terminal contract panics; cause keeps the original E |
unwrap.ts, expect.ts |
async-result’s combine / combineWithAllErrors |
The former fails fast, the latter accumulates errors; an empty array returns Ok([]); nothing runs at construction time, and run() starts every carrier through Promise.all (no short-circuit at execution time) |
combine.ts |
async-option’s map / mapAsync / bind / filter / tap / orElse / mapOr / okOrElse |
Capture to values (mapAsync captures fully, the opposite of async-result/mapAsync, by declaration; includes async rejections from the inner carrier of bind / orElse) |
map.ts, filter.ts |
async-option’s match / mapOrElse / unwrapOrElse / exists / zipWith |
Propagate directly | match.ts, zipWith.ts |
async-option’s unwrap |
Terminal contract panic (the only panic API in this layer) | unwrap.ts |
async-option’s all |
An empty array returns Some([]); any None returns None; Promise.all starts every carrier (see §5, item 3) |
all.ts |
4.8 Reliability (src/reliability/)
Section titled “4.8 Reliability (src/reliability/)”This is the only async layer that promises never to reject: synchronous throws, async rejections, and hook throws are all collapsed into Err.
| Exports | Behavior | Source |
|---|---|---|
retry / retryLazy |
Capture to values: a throw from fn becomes ThrownError (carrying the original value); an invalid times or an already-aborted signal returns AbortedError without running fn; onThrow / onAborted can collapse these into your own E; the lazy variant only postpones execution to run() |
retry.ts, retryLazy.ts |
timeout / timeoutEager |
Capture to values: an inner rejection becomes Err(reason), a timeout becomes Err(onTimeout(ms)); after a timeout the inner operation keeps running in the background (it cannot be cancelled) |
timeout.ts, timeoutEager.ts |
race |
The first Ok wins; if all fail, the first Err in input order; Err beats a rejection; if all reject, the earliest rejection; an empty array returns Err(EmptyInputsError), replaceable through onEmpty |
race.ts |
any |
Runs everything; returns the success set when there is one, otherwise the error set; rejections are marked { kind: 'Rejected' }; an empty array returns Ok([]) |
any.ts |
allSettled |
Always Ok: returns each outcome in input order; an empty array returns Ok([]) |
allSettled.ts |
4.9 Observability (src/observability/)
Section titled “4.9 Observability (src/observability/)”ctx / withPath maintain per-scope breadcrumb frames with AsyncLocalStorage (Node/Bun/Deno, falling back to a polyfill), isolated across await and supporting nested chains; observer errors from tapErrContext / observe / installObserver are always swallowed (including a second swallow inside onError) and never change the main flow. The format / inspect formatters never throw. See ctx.ts and the observability module spec.
5. Deliberate special cases and asymmetries
Section titled “5. Deliberate special cases and asymmetries”- The zero-panic API of
option/promise-*is deliberate:Optionexpresses absence withNone, so there is nothing to panic about; the eagerPromiselayer extracts withunwrapOr*to avoid opening a throw channel next to the rejection channel. async-optionhas onlyunwrap, noexpect/orThrow: this asymmetry against theasync-resultquartet means that at the end of a pipeline you first bridge toAsyncResultwithokOr/okOrElseand then throw a typed error.- Async combination does not short-circuit:
async-option/all,async-result/combine, andasync-result/combineWithAllErrorsall start every carrier throughPromise.all; “short-circuit” only shows in result selection (firstNone/ firstErr) or error accumulation, while every side effect still happens. Do not treat them as equivalents of the synchronouscombine/all. mapcaptures butbindpropagates: themapcallback is a pure value mapping, so capturing is safe; thebindcallback returns the nextResultand is a railway switch, so propagating avoids swallowing programming errors outside the railway.E = neveronliftdoes not mean the runtime cannot throw: without anerrorFnit rethrows as-is, and the type-levelneveronly marks that the caller has to catch it themselves.mapOrAsyncswallows observer exceptions: it returns the default value while swallowing a throw fromonErritself, so expect a lost cause when debugging.
6. Choosing an escape hatch
Section titled “6. Choosing an escape hatch”- Want a stack trace and an explicit misuse signal (tests, assertions): the
unwrap/expectfamily. - Want to hand a known error value to an outer
try/catch:unsafeUnwrap/unsafeUnwrapErr. - Want to throw a typed
Errorsubclass (production boundaries):orThrow(whenE extends Error) ororThrowWith(constructed with a mapping function). - At the end of an
AsyncResult: collapse with the terminalmatch/unwrapOrfirst, and when a throw is genuinely required useasync-result’sunwrap/expect(causekeeps the original error); at the end of anAsyncOption,okOrfirst and then throw.