Skip to content

Composition: pipe, composeK, safeTry

Composition utilities — barrel export.

Re-exports Kleisli composition and pipe utilities for Result pipelines.

Kleisli composition — composes N switch functions into one. Each function returns a Result, and the composed function chains them. Short-circuits on the first failure.

F# equivalent: f1 >=> f2 >=> f3

Compared to composeKAsync: this is the sync variant — each function must return IResultOfT synchronously. For async-compatible composition (callbacks may return Promise<IResultOfT>), use composeKAsync from @sandlada/result/composition.

Empty input guard: calling composeK() with zero functions throws TypeError at construction time. The async variant has the same policy.

Synchronous throw policy: if any function in the chain throws synchronously, the catch block funnels the unknown rejection through as unknown as IResultOfT<unknown, unknown> - the same honesty trade-off the sync composeK and the retry module make. The composed function’s declared error type E is therefore a structural claim only: a synchronous throw at runtime can produce an arbitrary unknown value. If you need precise error-type narrowing, wrap each step in tryCatch with an errorFn that maps to your E before composing.

export function composeK<A, B, E>(
f1: (a: A) => IResultOfT<B, E>,
): (a: A) => IResultOfT<B, E>;
export function composeK<A, B, C, E>(
f1: (a: A) => IResultOfT<B, E>,
f2: (b: B) => IResultOfT<C, E>,
): (a: A) => IResultOfT<C, E>;
export function composeK<A, B, C, D, E>(
f1: (a: A) => IResultOfT<B, E>,
f2: (b: B) => IResultOfT<C, E>,
f3: (c: C) => IResultOfT<D, E>,
): (a: A) => IResultOfT<D, E>;
export function composeK<A, B, C, D, F, E>(
f1: (a: A) => IResultOfT<B, E>,
f2: (b: B) => IResultOfT<C, E>,
f3: (c: C) => IResultOfT<D, E>,
f4: (d: D) => IResultOfT<F, E>,
): (a: A) => IResultOfT<F, E>;
export function composeK<A, B, C, D, F, G, E>(
f1: (a: A) => IResultOfT<B, E>,
f2: (b: B) => IResultOfT<C, E>,
f3: (c: C) => IResultOfT<D, E>,
f4: (d: D) => IResultOfT<F, E>,
f5: (f: F) => IResultOfT<G, E>,
): (a: A) => IResultOfT<G, E>;
export function composeK<A, B, C, D, F, G, H, E>(
f1: (a: A) => IResultOfT<B, E>,
f2: (b: B) => IResultOfT<C, E>,
f3: (c: C) => IResultOfT<D, E>,
f4: (d: D) => IResultOfT<F, E>,
f5: (f: F) => IResultOfT<G, E>,
f6: (g: G) => IResultOfT<H, E>,
): (a: A) => IResultOfT<H, E>;

Defined in: composition/composeK.ts

import { composeK } from '@sandlada/result/composition';
import { ok, err } from '@sandlada/result/factories';
import { match } from '@sandlada/result/operators';
const run = composeK(
(x: number) => ok(x * 2),
(x: number) => (x > 50 ? ok(x) : err('too small')),
);
const input = 30;
const result = run(input);
const label = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, result);
// label === 'ok:60'
const short = run(10);
const blocked = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, short);
// blocked === 'err:too small'; composeK stops at the first Err

If called with zero functions.

If any composed step throws synchronously, the thrown value is captured into the returned failure’s error field — but its static type widens to unknown, not the declared E. Use tryCatch(..., errorFn) per step if you need typed errors.


Kleisli composition for async switch functions. Each function can return IResultOfT or Promise<IResultOfT>.

F# equivalent: f1 >=> f2 >=> f3 (async)

Synchronous throw policy: if any function in the chain throws synchronously, the catch block funnels the unknown rejection through as unknown as IResultOfT<unknown, unknown> - the same honesty trade-off the sync composeK and the retry module make. The composed function’s declared error type E is therefore a structural claim only: a synchronous throw at runtime can produce an arbitrary unknown value.

export function composeKAsync<A, B, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
): (a: A) => Promise<IResultOfT<B, E>>;
export function composeKAsync<A, B, C, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
f2: (b: B) => IResultOfT<C, E> | Promise<IResultOfT<C, E>>,
): (a: A) => Promise<IResultOfT<C, E>>;
export function composeKAsync<A, B, C, D, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
f2: (b: B) => IResultOfT<C, E> | Promise<IResultOfT<C, E>>,
f3: (c: C) => IResultOfT<D, E> | Promise<IResultOfT<D, E>>,
): (a: A) => Promise<IResultOfT<D, E>>;
export function composeKAsync<A, B, C, D, F, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
f2: (b: B) => IResultOfT<C, E> | Promise<IResultOfT<C, E>>,
f3: (c: C) => IResultOfT<D, E> | Promise<IResultOfT<D, E>>,
f4: (d: D) => IResultOfT<F, E> | Promise<IResultOfT<F, E>>,
): (a: A) => Promise<IResultOfT<F, E>>;
export function composeKAsync<A, B, C, D, F, G, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
f2: (b: B) => IResultOfT<C, E> | Promise<IResultOfT<C, E>>,
f3: (c: C) => IResultOfT<D, E> | Promise<IResultOfT<D, E>>,
f4: (d: D) => IResultOfT<F, E> | Promise<IResultOfT<F, E>>,
f5: (f: F) => IResultOfT<G, E> | Promise<IResultOfT<G, E>>,
): (a: A) => Promise<IResultOfT<G, E>>;
export function composeKAsync<A, B, C, D, F, G, H, E>(
f1: (a: A) => IResultOfT<B, E> | Promise<IResultOfT<B, E>>,
f2: (b: B) => IResultOfT<C, E> | Promise<IResultOfT<C, E>>,
f3: (c: C) => IResultOfT<D, E> | Promise<IResultOfT<D, E>>,
f4: (d: D) => IResultOfT<F, E> | Promise<IResultOfT<F, E>>,
f5: (f: F) => IResultOfT<G, E> | Promise<IResultOfT<G, E>>,
f6: (g: G) => IResultOfT<H, E> | Promise<IResultOfT<H, E>>,
): (a: A) => Promise<IResultOfT<H, E>>;

Defined in: composition/composeKAsync.ts

import { composeKAsync } from '@sandlada/result/composition';
import { asyncOk, asyncErr } from '@sandlada/result/factories';
import { matchAsync } from '@sandlada/result/promise-result';
const run = composeKAsync(
(x: number) => asyncOk(x * 2),
(x: number) => (x > 50 ? asyncOk(x) : asyncErr('too small')),
);
const input = 30;
const label = await matchAsync(
(n) => `ok:${n}`,
(e) => `err:${String(e)}`,
run(input),
);
// label === 'ok:60'
const blocked = await matchAsync(
(n) => `ok:${n}`,
(e) => `err:${String(e)}`,
run(10),
);
// blocked === 'err:too small'; composeKAsync stops at the first Err

If called with zero functions.

If any composed step throws synchronously or rejects asynchronously, the thrown/rejected value is captured into the returned failure’s error field — but its static type widens to unknown, not the declared E. Use tryCatchAsync(..., errorFn) per step if you need typed errors.


Evaluates a generator function that uses yield* safeTry(...) and collects the final IResultOfT.

  • If the generator returns a value: the value is wrapped in ok().
  • If the generator returns a Result: that Result is used as-is, so the documented return ok(undefined) workaround yields Ok(undefined) instead of a doubly wrapped Result.
  • If the generator yields a value: that yield is treated as a propagated failure and returned as-is. The yielded value must be a Result - a yield that is not one throws a TypeError, because the runner would otherwise hand back a value that is not a Result at all.

A generator that yields twice throws - the runner probes a raw yield for a second one before reporting the failure.

export function fromSafeTry<T, E>(
gen: () => Generator<IResultOfT<never, E>, IResultOfT<T, E>, unknown>,
): IResultOfT<T, E>;
export function fromSafeTry<T, E>(
gen: () => Generator<IResultOfT<never, E>, T | undefined, unknown>,
): IResultOfT<T, E>;

Defined in: composition/safeTry.ts

import { fromSafeTry, safeTry } from '@sandlada/result/composition';
import { ok, err } from '@sandlada/result/factories';
import { match } from '@sandlada/result/operators';
const step = ok(42);
const result = fromSafeTry(function* () {
const value = yield* safeTry(step);
return value;
});
const label = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, result);
// label === 'ok:42'
const aborted = fromSafeTry(function* () {
yield* safeTry(err('boom'));
return 0;
});
const blocked = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, aborted);
// blocked === 'err:boom'; fromSafeTry returns the propagated failure

Runs an async generator that uses yield* safeTryAsync(...) and returns a lazy AsyncResult collecting the final IResultOfT.

  • If the generator returns a value: the value is wrapped in Ok when .run() is called.
  • If the generator returns a Result: that Result is used as-is, so the documented return asyncOk(undefined) workaround yields Ok(undefined) instead of a doubly wrapped Result.
  • If the generator yields a value: that yield is treated as a propagated failure and returned as-is. The yielded value must be a Result - a yield that is not one throws a TypeError, because the runner would otherwise hand back a value that is not a Result at all.

Throws when the generator returns undefined without yielding, or yields more than once.


export function fromSafeTryAsync<T, E>(
gen: () => AsyncGenerator<IResultOfT<never, E>, IResultOfT<T, E>, unknown>,
): AsyncResult<T, E>;
export function fromSafeTryAsync<T, E>(
gen: () => AsyncGenerator<IResultOfT<never, E>, T | undefined, unknown>,
): AsyncResult<T, E>;

Defined in: composition/safeTryAsync.ts

Pipes a value through a sequence of functions (left-to-right composition). Each function receives the output of the previous one.

F# equivalent: value |> fn1 |> fn2 |> fn3

export function pipe<A>(value: A): A;
export function pipe<A, B>(value: A, fn1: (a: A) => B): B;
export function pipe<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C;
export function pipe<A, B, C, D>(value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): D;
export function pipe<A, B, C, D, E>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D, fn4: (d: D) => E,
): E;
export function pipe<A, B, C, D, E, F>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D, fn4: (d: D) => E, fn5: (e: E) => F,
): F;
export function pipe<A, B, C, D, E, F, G>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G,
): G;
export function pipe<A, B, C, D, E, F, G, H>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H,
): H;
export function pipe<A, B, C, D, E, F, G, H, I>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
): I;
export function pipe<A, B, C, D, E, F, G, H, I, J>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
fn9: (i: I) => J,
): J;
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
fn9: (i: I) => J, fn10: (j: J) => K,
): K;

Defined in: composition/pipe.ts

import { pipe } from '@sandlada/result/composition';
import { map, bind, match } from '@sandlada/result/operators';
import { ok, err } from '@sandlada/result/factories';
const input = ok(42);
const outcome = pipe(
input,
map((x: number) => x * 2),
bind((x: number) => (x > 50 ? ok(x) : err('too small'))),
match((v) => `OK: ${v}`, (e) => `Error: ${e}`),
);
// outcome === 'OK: 84'
const blocked = pipe(
ok(10),
map((x: number) => x * 2),
bind((x: number) => (x > 50 ? ok(x) : err('too small'))),
match((v) => `OK: ${v}`, (e) => `Error: ${e}`),
);
// blocked === 'Error: too small'; bind short-circuits before match sees a success

Async version of pipe. Pipes a value through a sequence of async functions. Each function receives the output of the previous one.

export function pipeAsync<A>(value: A): Promise<A>;
export function pipeAsync<A, B>(value: A, fn1: (a: A) => B): Promise<B>;
export function pipeAsync<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): Promise<C>;
export function pipeAsync<A, B, C, D>(value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): Promise<D>;
export function pipeAsync<A, B, C, D, E>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D, fn4: (d: D) => E,
): Promise<E>;
export function pipeAsync<A, B, C, D, E, F>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D, fn4: (d: D) => E, fn5: (e: E) => F,
): Promise<F>;
export function pipeAsync<A, B, C, D, E, F, G>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G,
): Promise<G>;
export function pipeAsync<A, B, C, D, E, F, G, H>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H,
): Promise<H>;
export function pipeAsync<A, B, C, D, E, F, G, H, I>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
): Promise<I>;
export function pipeAsync<A, B, C, D, E, F, G, H, I, J>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
fn9: (i: I) => J,
): Promise<J>;
export function pipeAsync<A, B, C, D, E, F, G, H, I, J, K>(
value: A, fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D,
fn4: (d: D) => E, fn5: (e: E) => F, fn6: (f: F) => G, fn7: (g: G) => H, fn8: (h: H) => I,
fn9: (i: I) => J, fn10: (j: J) => K,
): Promise<K>;

Defined in: composition/pipeAsync.ts

import { pipeAsync } from '@sandlada/result/composition';
import { asyncOk, asyncErr } from '@sandlada/result/factories';
import { mapAsync, bindAsync, matchAsync } from '@sandlada/result/promise-result';
const input = asyncOk(42);
const outcome = await pipeAsync(
input,
mapAsync((x: number) => x * 2),
bindAsync((x: number) => (x > 50 ? asyncOk(x) : asyncErr('too small'))),
matchAsync((v) => `OK: ${v}`, (e) => `Error: ${e}`),
);
// outcome === 'OK: 84'
const blocked = await pipeAsync(
asyncOk(10),
mapAsync((x: number) => x * 2),
bindAsync((x: number) => (x > 50 ? asyncOk(x) : asyncErr('too small'))),
matchAsync((v) => `OK: ${v}`, (e) => `Error: ${e}`),
);
// blocked === 'Error: too small'; bindAsync short-circuits before matchAsync runs on success

Generator-based yield* error propagation for Result pipelines.

safeTry wraps a Result into a Generator. On success it returns the value; on failure it yields the error, propagating it up to a fromSafeTry runner. This enables flat, non-nested error handling in complex sequential logic.

Wraps a IResultOfT<T, E> into a generator for use with yield*:

  • On success: the generator returns the value — yield* evaluates to it.
  • On failure: the generator yields the error result — fromSafeTry catches it.

Returns T when the inner result is Ok, otherwise yields the failure to be collected by fromSafeTry. The Generator’s return type is T | undefined: the success path returns T, and the failure path’s unreachable tail explicitly returns undefined (matches JS semantics when a generator exhausts after a yield without a top-level return).

The failure is yielded, and the generator throws when it is resumed past that yield. A runner that resumes a delegation therefore ends the generator instead of running the body after the failed yield* - the short-circuit contract holds without any bookkeeping that would have to be attributed to one specific run.

export function* safeTry<T, E>(
result: IResultOfT<T, E>,
): Generator<IResultOfT<never, E>, T | undefined, unknown>;

Defined in: composition/safeTry.ts

import { safeTry, fromSafeTry } from '@sandlada/result/composition';
import { ok, err } from '@sandlada/result/factories';
import { match, map } from '@sandlada/result/operators';
const step = ok(42);
const success = fromSafeTry(function* () {
const value = yield* safeTry(step);
return value;
});
const doubled = map((n: number) => n * 2, success);
const label = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, doubled);
// label === 'ok:84'
const failure = fromSafeTry(function* () {
yield* safeTry(err('skip'));
return 0;
});
const blocked = match((n) => `ok:${n}`, (e) => `err:${String(e)}`, failure);
// blocked === 'err:skip'; safeTry yields Err so code after yield* never runs

Async Generator-based yield* error propagation for AsyncResult pipelines.

safeTryAsync wraps an AsyncResult (or a Promise<IResultOfT>) into an AsyncGenerator. On success it returns the value; on failure it yields the error so that fromSafeTryAsync can short-circuit the pipeline and return the error result.

Returns T when the inner result is Ok, otherwise yields the failure to be collected by fromSafeTryAsync. The AsyncGenerator’s return type is T | undefined: the success path returns T, and the failure path’s unreachable tail returns undefined (matches JS semantics when a generator exhausts after a yield without a top-level return).

The failure is yielded, and the generator throws when it is resumed past that yield. A runner that resumes a delegation therefore ends the generator instead of running the body after the failed yield* - the short-circuit contract holds without any bookkeeping that would have to be attributed to one specific run, which matters when several runs are in flight at the same time.

export async function* safeTryAsync<T, E>(
result: AsyncResult<T, E> | Promise<IResultOfT<T, E>>,
): AsyncGenerator<IResultOfT<never, E>, T | undefined, unknown>;

Defined in: composition/safeTryAsync.ts

import { safeTryAsync, fromSafeTryAsync } from '@sandlada/result/composition';
import { asyncOk, asyncErr } from '@sandlada/result/factories';
import { matchAsync } from '@sandlada/result/promise-result';
const first = asyncOk(40);
const second = asyncOk(2);
const pipeline = fromSafeTryAsync(async function* () {
const a = yield* safeTryAsync(first);
const b = yield* safeTryAsync(second);
return Number(a) + Number(b);
});
const label = await matchAsync(
(n) => `ok:${n}`,
(e) => `err:${String(e)}`,
pipeline.run(),
);
// label === 'ok:42'
const failure = fromSafeTryAsync(async function* () {
yield* safeTryAsync(asyncErr('boom'));
return 0;
});
const blocked = await matchAsync(
(n) => `ok:${n}`,
(e) => `err:${String(e)}`,
failure.run(),
);
// blocked === 'err:boom'; safeTryAsync short-circuits before return 0 runs