Skip to content

Lazy AsyncOption operators

AsyncOption — barrel export.

Re-exports all AsyncOption factories and operators.

Combines a tuple/array of AsyncOptions, preserving heterogeneous types. Returns AsyncOption<None> if any element is None; otherwise AsyncOption<Some<[v0, v1, ...]>>. Like Promise.all but with None short-circuiting.

Two overloads:

  • Tuple overload: preserves per-position heterogeneous types — yields AsyncOption<Some<[number, string]>>.
  • Array overload: runtime-sized homogeneous arrays — yields AsyncOption<Some<T[]>>.
export function all<T extends readonly [AsyncOption<unknown>, ...AsyncOption<unknown>[]]>(
aos: T,
): AsyncOption<
{ [K in keyof T]: T[K] extends AsyncOption<infer V> ? V : never }
>;
export function all<T>(
aos: readonly AsyncOption<T>[],
): AsyncOption<T[]>;

Defined in: async-option/all.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { all, fromOption } from '@sandlada/result/async-option';
const left = fromOption(ofSome(1));
const right = fromOption(ofSome(2));
const combined = all([left, right]);
const pair = await combined.run();
// pair is Some([1, 2])
const broken = all([fromOption(ofSome(1)), fromOption(ofNone<number>())]);
const none = await broken.run();
// none is None; all short-circuits when any input is None

Chains an AsyncOption-returning function on success (monadic bind / flatMap). Supports interoperability by also accepting a function that returns Promise<IOption<U>>. Lazy — returns a new AsyncOption without executing the inner computation.

export function bind<T, U>(
fn: (value: T) => AsyncOption<U> | Promise<IOption<U>>,
): (ao: AsyncOption<T>) => AsyncOption<U>;
export function bind<T, U>(
fn: (value: T) => AsyncOption<U> | Promise<IOption<U>>,
ao: AsyncOption<T>,
): AsyncOption<U>;

Defined in: async-option/bind.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { bind, fromOption, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const chained = bind((x: number) => fromOption(ofSome(x * 2)), input);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
chained,
);
// label === 'value:42'
const empty = fromOption(ofNone<number>());
const skipped = bind((x: number) => fromOption(ofSome(x * 2)), empty);
const absent = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
skipped,
);
// absent === 'missing'; bind short-circuits on None

Returns a Promise<boolean> indicating if the AsyncOption is Some and contains the given value.


export function contains<T>(
value: T,
): (ao: AsyncOption<T>) => Promise<boolean>;
export function contains<T>(
value: T,
ao: AsyncOption<T>,
): Promise<boolean>;

Defined in: async-option/contains.ts

Returns a Promise<boolean> indicating if the AsyncOption is Some and the predicate holds.

Throw policy: a predicate that throws synchronously or rejects is collapsed to false - the same contract the eager sibling promise-option/existsAsyncOption has, and the convention of this module, where every callback captures its failure instead of propagating it.


export function exists<T>(
predicate: (value: T) => boolean | Promise<boolean>,
): (ao: AsyncOption<T>) => Promise<boolean>;
export function exists<T>(
predicate: (value: T) => boolean | Promise<boolean>,
ao: AsyncOption<T>,
): Promise<boolean>;

Defined in: async-option/exists.ts

Filters the value of an AsyncOption with a predicate. Keeps the Some if the predicate holds; converts to None otherwise. None passes through. Lazy — returns a new AsyncOption without executing the inner computation.

Throw policy: If the predicate throws synchronously or returns a rejected Promise, the error is caught and the result converts to None (canonical catch+convert policy — see AGENTS.md).

export function filter<T>(
predicate: (value: T) => boolean | Promise<boolean>,
): (ao: AsyncOption<T>) => AsyncOption<T>;
export function filter<T>(
predicate: (value: T) => boolean | Promise<boolean>,
ao: AsyncOption<T>,
): AsyncOption<T>;

Defined in: async-option/filter.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { filter, fromOption, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const kept = filter((x: number) => x > 10, input);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
kept,
);
// label === 'value:21'
const narrow = filter((x: number) => x > 100, input);
const rejected = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
narrow,
);
// rejected === 'missing'; filter drops failing predicates to None
const empty = fromOption(ofNone<number>());
const skipped = filter((x: number) => x > 10, empty);
const absent = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
skipped,
);
// absent === 'missing'; filter passes None through

Flattens a nested AsyncOption.

Single-step only: unwraps exactly one layer. Call flatten repeatedly to flatten deeper nests.


export function flatten<T>(
ao: AsyncOption<AsyncOption<T>>,
): AsyncOption<T>;

Defined in: async-option/flatten.ts

Creates an AsyncOption from a thunk that returns an Option or a Promise<Option>. The thunk is lazy - it won’t execute until .run() is called.

The carrier’s run() always returns a Promise: a thunk that returns a plain Option is awaited, and a thunk that throws synchronously surfaces as a rejected promise instead of escaping as a raw exception.

export function from<T>(
thunk: () => IOption<T> | Promise<IOption<T>>,
): AsyncOption<T>;

Defined in: async-option/from.ts

import { ofSome } from '@sandlada/result/option';
import { from, map } from '@sandlada/result/async-option';
const input = () => Promise.resolve(ofSome(21));
const ao = map((x: number) => x * 2, from(input));
const result = await ao.run();
// result is Some(42); from defers the thunk until run()

Wraps a sync IOption into an AsyncOption (lifts a sync Option into the async world).

export function fromOption<T>(
option: IOption<T>,
): AsyncOption<T>;

Defined in: async-option/fromOption.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match } from '@sandlada/result/async-option';
const input = ofSome(42);
const ao = fromOption(input);
const label = await match(
{ some: (n) => `some:${n}`, none: () => 'none' },
ao,
);
// label === 'some:42'
const missing = fromOption(ofNone<number>());
const absent = await match(
{ some: (n) => `some:${n}`, none: () => 'none' },
missing,
);
// absent === 'none'

Wraps a Promise<T> into an AsyncOption, catching rejections. If the promise resolves, it returns Some(value). If it rejects, it returns None.

The inner Promise is not yet created at construction time; the factory thunk is invoked lazily when .run() is called.

export function fromPromise<T>(
thunk: () => Promise<T>,
): AsyncOption<T>;

Defined in: async-option/fromPromise.ts

import { fromPromise, match } from '@sandlada/result/async-option';
const input = () => Promise.resolve(99);
const ao = fromPromise(input);
const label = await match(
{ some: (n) => `got:${n}`, none: () => 'missing' },
ao,
);
// label === 'got:99'
const rejected = fromPromise(() => Promise.reject(new Error('fail')));
const absent = await match(
{ some: () => 'ok', none: () => 'missing' },
rejected,
);
// absent === 'missing'; fromPromise turns rejections into None

Returns true if the AsyncOption resolves to None. Mirrors the IOption.isNone discriminator as a standalone function.

export function isNone<T>(ao: AsyncOption<T>): Promise<boolean>;

Defined in: async-option/isNone.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, isNone, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const no = await isNone(input);
// no === false
const label = await match(
{ some: () => 'some', none: () => 'none' },
input,
);
// label === 'some'
const empty = fromOption(ofNone<number>());
const yes = await isNone(empty);
// yes === true

Returns true if the AsyncOption resolves to Some. Mirrors the IOption.isSome discriminator as a standalone function.

export function isSome<T>(ao: AsyncOption<T>): Promise<boolean>;

Defined in: async-option/isSome.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, isSome, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const yes = await isSome(input);
// yes === true
const label = await match(
{ some: () => 'some', none: () => 'none' },
input,
);
// label === 'some'
const empty = fromOption(ofNone<number>());
const no = await isSome(empty);
// no === false

Maps the value of an AsyncOption using a synchronous function. Lazy — returns a new AsyncOption without executing the inner computation.

export function map<T, U>(
fn: (value: T) => U,
): (ao: AsyncOption<T>) => AsyncOption<U>;
export function map<T, U>(
fn: (value: T) => U,
ao: AsyncOption<T>,
): AsyncOption<U>;

Defined in: async-option/map.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, map, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const mapped = map((x: number) => x * 2, input);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
mapped,
);
// label === 'value:42'
const empty = fromOption(ofNone<number>());
const skipped = map((x: number) => x * 2, empty);
const absent = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
skipped,
);
// absent === 'missing'; map passes None through

Maps the value of an AsyncOption using an async function. Lazy — returns a new AsyncOption without executing the inner computation.

export function mapAsync<T, U>(
fn: (value: T) => Promise<U>,
): (ao: AsyncOption<T>) => AsyncOption<U>;
export function mapAsync<T, U>(
fn: (value: T) => Promise<U>,
ao: AsyncOption<T>,
): AsyncOption<U>;

Defined in: async-option/mapAsync.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, mapAsync, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const mapped = mapAsync(async (x: number) => x * 2, input);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
mapped,
);
// label === 'value:42'
const empty = fromOption(ofNone<number>());
const skipped = mapAsync(async (x: number) => x * 2, empty);
const absent = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
skipped,
);
// absent === 'missing'; mapAsync passes None through

Maps the value of an AsyncOption, returning a default on None. The mapper may be sync or async. Throws from the mapper are caught and converted to the default (canonical AsyncOption catch+convert policy).

export function mapOr<T, U>(
defaultValue: U,
fn: (value: T) => U | Promise<U>,
): (ao: AsyncOption<T>) => Promise<U>;
export function mapOr<T, U>(
defaultValue: U,
fn: (value: T) => U | Promise<U>,
ao: AsyncOption<T>,
): Promise<U>;

Defined in: async-option/mapOr.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, mapOr } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const mapped = await mapOr(-1, (x: number) => x * 2, input);
// mapped === 42
const empty = fromOption(ofNone<number>());
const fallback = await mapOr(-1, (x: number) => x * 2, empty);
// fallback === -1; mapOr uses the default on None

Maps the value of an AsyncOption, or computes a default from a thunk on None. Both callbacks may be sync or async.

export function mapOrElse<T, U>(
onNone: () => U | Promise<U>,
fn: (value: T) => U | Promise<U>,
): (ao: AsyncOption<T>) => Promise<U>;
export function mapOrElse<T, U>(
onNone: () => U | Promise<U>,
fn: (value: T) => U | Promise<U>,
ao: AsyncOption<T>,
): Promise<U>;

Defined in: async-option/mapOrElse.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, mapOrElse } from '@sandlada/result/async-option';
const input = fromOption(ofSome(21));
const mapped = await mapOrElse(() => -1, (x: number) => x * 2, input);
// mapped === 42
const empty = fromOption(ofNone<number>());
const fallback = await mapOrElse(() => -1, (x: number) => x * 2, empty);
// fallback === -1; mapOrElse runs onNone when input is None

Terminal — pattern-matches on both cases of an AsyncOption.

export function match<T, U>(
handlers: { some: (value: T) => U | Promise<U>; none: () => U | Promise<U> },
): (ao: AsyncOption<T>) => Promise<U>;
export function match<T, U>(
handlers: { some: (value: T) => U | Promise<U>; none: () => U | Promise<U> },
ao: AsyncOption<T>,
): Promise<U>;

Defined in: async-option/match.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const label = await match(
{ some: (v: number) => `success: ${v}`, none: () => 'failure' },
input,
);
// label === 'success: 42'
const empty = fromOption(ofNone<number>());
const absent = await match(
{ some: (v: number) => `success: ${v}`, none: () => 'failure' },
empty,
);
// absent === 'failure'

Creates an AsyncOption that always resolves to None. Equivalent to fromOption(ofNone()) but skips the sync intermediate.

Default T = unknown mirrors option/ofNone so contextual typing flows the same way (const x: AsyncOption<number> = ofNone() widens without needing an explicit generic argument).

export function ofNone<T = unknown>(): AsyncOption<T>;

Defined in: async-option/ofNone.ts

import { ofSome } from '@sandlada/result/option';
import { fromOption, match, ofNone, orElse } from '@sandlada/result/async-option';
const input = ofNone<number>();
const recovered = orElse(() => fromOption(ofSome(0)), input);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
recovered,
);
// label === 'value:0'; orElse supplies Some when input is None

Lifts a raw value into an AsyncOption<T> that resolves to Some(value). Equivalent to fromOption(ofSome(value)) but skips the sync intermediate.

export function ofSome<T>(value: T): AsyncOption<T>;

Defined in: async-option/ofSome.ts

import { map, match, ofSome } from '@sandlada/result/async-option';
const input = 21;
const ao = map((x: number) => x * 2, ofSome(input));
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
ao,
);
// label === 'value:42'

Converts an AsyncOption<T> into an AsyncResult<T, E>, supplying an error value for the None case.

export function okOr<T, E>(
error: E,
): (ao: AsyncOption<T>) => AsyncResult<T, E>;
export function okOr<T, E>(
error: E,
ao: AsyncOption<T>,
): AsyncResult<T, E>;

Defined in: async-option/okOr.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, okOr } from '@sandlada/result/async-option';
import { match } from '@sandlada/result/async-result';
const input = fromOption(ofSome(42));
const ar = okOr('missing', input);
const okLabel = await match(
{ ok: (v: number) => `ok:${v}`, err: (e: string) => `err:${e}` },
ar,
);
// okLabel === 'ok:42'
const empty = fromOption(ofNone<number>());
const failed = okOr('missing', empty);
const errLabel = await match(
{ ok: (v: number) => `ok:${v}`, err: (e: string) => `err:${e}` },
failed,
);
// errLabel === 'err:missing'

Converts an AsyncOption<T> into an AsyncResult<T, E | Error>, computing the error from a thunk on None (lazy - error is only built when needed).

Throw policy: a thunk that throws synchronously or rejects is captured as the result error. Because the caught value can be any unknown, the return type is widened to E | Error - the runtime error may be an Error instance even when the user-declared E is something else. A caught value that is not an Error instance is wrapped in one (the original value stays available through cause), so the declared error type holds for every value the caller can receive.

export function okOrElse<T, E>(
onNone: () => E | Promise<E>,
): (ao: AsyncOption<T>) => AsyncResult<T, E | Error>;
export function okOrElse<T, E>(
onNone: () => E | Promise<E>,
ao: AsyncOption<T>,
): AsyncResult<T, E | Error>;

Defined in: async-option/okOrElse.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, okOrElse } from '@sandlada/result/async-option';
import { match } from '@sandlada/result/async-result';
const input = fromOption(ofSome(42));
const ar = okOrElse(() => 'missing', input);
const okLabel = await match(
{ ok: (v: number) => `ok:${v}`, err: (e: string | Error) => `err:${String(e)}` },
ar,
);
// okLabel === 'ok:42'
const empty = fromOption(ofNone<number>());
const failed = okOrElse(() => 'missing', empty);
const errLabel = await match(
{ ok: (v: number) => `ok:${v}`, err: (e: string | Error) => `err:${String(e)}` },
failed,
);
// errLabel === 'err:missing'

Curried form. The inner <T> is deferred so the AsyncOption’s value type is re-inferred at every application site.

Recovers from None by chaining to an alternative AsyncOption or Promise<IOption>. Lazy — returns a new AsyncOption without executing the inner computation.

The fallback’s value type (U) is independent of the input’s value type (T), so a recovery can return a structurally broader or different value — the resulting carrier is AsyncOption<T | U>. Pattern-matching on the result narrows to T on the original Some and to U on the recovered branch.

export function orElse<U>(
fn: () => AsyncOption<U> | Promise<IOption<U>>,
): <T>(ao: AsyncOption<T>) => AsyncOption<T | U>;
export function orElse<T, U>(
fn: () => AsyncOption<U> | Promise<IOption<U>>,
ao: AsyncOption<T>,
): AsyncOption<T | U>;

Defined in: async-option/orElse.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match, orElse } from '@sandlada/result/async-option';
const empty = fromOption(ofNone<number>());
const recovered = orElse(() => fromOption(ofSome(0)), empty);
const label = await match(
{ some: (n) => `value:${n}`, none: () => 'missing' },
recovered,
);
// label === 'value:0'
const present = fromOption(ofSome(5));
const kept = orElse(() => fromOption(ofSome(0)), present);
const same = await kept.run();
// same is Some(5); orElse keeps the original Some

Direct form. T is inferred from the supplied AsyncOption; the result widens to T | U.


Side-effect on the success track of an AsyncOption. Calls fn with the value on Some and passes the original Option through unchanged. Lazy — returns a new AsyncOption without executing the inner computation.

Throw policy: If the side-effect callback throws, the result converts to None (canonical tap/tee policy — see AGENTS.md).

export function tap<T>(
fn: (value: T) => void,
): (ao: AsyncOption<T>) => AsyncOption<T>;
export function tap<T>(
fn: (value: T) => void,
ao: AsyncOption<T>,
): AsyncOption<T>;

Defined in: async-option/tap.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match, tap } from '@sandlada/result/async-option';
let seen = 0;
const input = fromOption(ofSome(42));
const tapped = tap((v: number) => { seen = v; }, input);
const kept = await tapped.run();
// kept is Some(42) and seen === 42
const empty = fromOption(ofNone<number>());
const skipped = tap((v: number) => { seen = v; }, empty);
const absent = await match(
{ some: () => 'some', none: () => 'none' },
skipped,
);
// absent === 'none'; tap passes None through without calling the side effect

Side-effect on the success track of an AsyncOption using an async function. Calls fn with the value on Some and passes the original Option through unchanged. Lazy — returns a new AsyncOption without executing the inner computation.

Throw policy: If the side-effect callback throws (or rejects), the result converts to None (canonical tap/tee policy — see AGENTS.md).

export function tapAsync<T>(
fn: (value: T) => void | Promise<void>,
): (ao: AsyncOption<T>) => AsyncOption<T>;
export function tapAsync<T>(
fn: (value: T) => void | Promise<void>,
ao: AsyncOption<T>,
): AsyncOption<T>;

Defined in: async-option/tapAsync.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match, tapAsync } from '@sandlada/result/async-option';
let saved = false;
const input = fromOption(ofSome(42));
const tapped = tapAsync(async (v: number) => { saved = v === 42; }, input);
const kept = await tapped.run();
// kept is Some(42) and saved === true
const empty = fromOption(ofNone<number>());
const skipped = tapAsync(async () => { saved = true; }, empty);
const absent = await match(
{ some: () => 'some', none: () => 'none' },
skipped,
);
// absent === 'none'; tapAsync skips the side effect on None

Transposes an AsyncOption<AsyncResult<T, E>> into an AsyncResult<AsyncOption<T>, E>.

  • Some(Ok(v)) → Ok(Some(v))
  • Some(Err(e)) → Err(e)
  • None → Ok(None)
export function transpose<T, E>(
ao: AsyncOption<AsyncResult<T, E>>,
): AsyncResult<AsyncOption<T>, E>;

Defined in: async-option/transpose.ts

import { ofNone, ofSome, transpose } from '@sandlada/result/async-option';
import { fromResult, match } from '@sandlada/result/async-result';
import { ok } from '@sandlada/result/factories';
import type { AsyncResult } from '@sandlada/result/types';
const input = ofSome(fromResult(ok(42)));
const ar = transpose(input);
const label = await match(
{
ok: async (nested) => {
const opt = await nested.run();
return opt.isSome ? `some:${opt.value}` : 'none';
},
err: (e: string) => `err:${e}`,
},
ar,
);
// label === 'some:42'
const empty = ofNone<AsyncResult<number, string>>();
const whenNone = await match(
{
ok: async (nested) => {
const opt = await nested.run();
return opt.isSome ? 'some' : 'none';
},
err: (e: string) => `err:${e}`,
},
transpose(empty),
);
// whenNone === 'none'; outer None becomes Ok carrying a None AsyncOption

Extracts the value from an AsyncOption, or throws if None. Use sparingly — prefer unwrapOr, unwrapOrElse, or match in most code.

export function unwrap<T>(ao: AsyncOption<T>): Promise<T>;

Defined in: async-option/unwrap.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, isNone, unwrap } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const value = await unwrap(input);
// value === 42
const empty = fromOption(ofNone<number>());
const missing = await isNone(empty);
// missing === true; unwrap(empty) would throw on None

Extracts the value from an AsyncOption, or returns a default value.

export function unwrapOr<T>(
defaultValue: T | Promise<T>,
): (ao: AsyncOption<T>) => Promise<T>;
export function unwrapOr<T>(
defaultValue: T | Promise<T>,
ao: AsyncOption<T>,
): Promise<T>;

Defined in: async-option/unwrapOr.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, unwrapOr } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const value = await unwrapOr(0, input);
// value === 42
const empty = fromOption(ofNone<number>());
const fallback = await unwrapOr(0, empty);
// fallback === 0; unwrapOr returns the default on None

Extracts the value from an AsyncOption, or computes a default from a thunk on None. Lazy — the default is only computed when needed.

export function unwrapOrElse<T>(
onNone: () => T | Promise<T>,
): (ao: AsyncOption<T>) => Promise<T>;
export function unwrapOrElse<T>(
onNone: () => T | Promise<T>,
ao: AsyncOption<T>,
): Promise<T>;

Defined in: async-option/unwrapOrElse.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, unwrapOrElse } from '@sandlada/result/async-option';
const input = fromOption(ofSome(42));
const value = await unwrapOrElse(() => 0, input);
// value === 42
const empty = fromOption(ofNone<number>());
const fallback = await unwrapOrElse(() => 0, empty);
// fallback === 0; unwrapOrElse runs onNone when input is None

Combines N AsyncOptions (N ≥ 2) with a function. If all resolve to Some, returns AsyncOption<Some(fn(a, b, ...))>. If any resolves to None, returns AsyncOption<None>. A synchronous throw from the callback collapses to None; async rejections from the callback propagate (they are not caught).

The arity of fn fixes the number of AsyncOptions accepted — variadic via tuple inference. The catch-all mapped-type variadic handles every arity ≥ 2 in a single pair of overloads.

Design note (type safety): This file intentionally does NOT declare per-arity overloads. TypeScript’s function-type bivariance would let a 2-argument fn match a 3-argument overload (with the third parameter silently ignored), and a 0-argument fn match a 2-argument curried form. The single variadic below avoids both holes: T is inferred from fn’s actual parameter list, and the constraint readonly [unknown, unknown, ...unknown[]] requires ≥ 2 elements.

export function zipWith<T extends readonly [unknown, unknown, ...unknown[]], R>(
fn: (...args: T) => R | Promise<R>,
): (...aos: { [K in keyof T]: AsyncOption<T[K]> }) => AsyncOption<R>;
export function zipWith<T extends readonly [unknown, unknown, ...unknown[]], R>(
fn: (...args: T) => R | Promise<R>,
...aos: { [K in keyof T]: AsyncOption<T[K]> }
): AsyncOption<R>;

Defined in: async-option/zipWith.ts

import { ofSome, ofNone } from '@sandlada/result/option';
import { fromOption, match, zipWith } from '@sandlada/result/async-option';
const left = fromOption(ofSome(1));
const right = fromOption(ofSome(2));
const summed = zipWith((a: number, b: number) => a + b, left, right);
const label = await match(
{ some: (n) => `sum:${n}`, none: () => 'missing' },
summed,
);
// label === 'sum:3'
const broken = zipWith(
(a: number, b: number) => a + b,
left,
fromOption(ofNone<number>()),
);
const absent = await match(
{ some: (n) => `sum:${n}`, none: () => 'missing' },
broken,
);
// absent === 'missing'; zipWith short-circuits when any input is None