@deessejs/fp

AsyncResult

Async error handling with Thenable interface for chained async operations

The AsyncResult type is the asynchronous version of Result. It provides a Thenable interface that allows chaining async operations without intermediate await calls, while maintaining proper error handling.

Why AsyncResult?

Async/await is great, but error handling with try/catch is error-prone. AsyncResult makes async errors explicit in your types.

The Problem with Try/Catch

// Easy to forget to catch errors
const user = await fetchUser(id);
// If fetchUser throws, we have an unhandled rejection

The AsyncResult Solution

import { fromPromise, isOk } from '@deessejs/fp';

const result = fromPromise(fetchUser(id));

if (isOk(result)) {
  console.log(result.value);
} else {
  console.error(result.error);
}

Key concepts:

  • AsyncResult<T, E> - Async success/failure with Thenable interface
  • fromPromise(promise) - Wraps a Promise, catching rejections as PanicError
  • await directly - AsyncResult is Thenable, so use await directly on it
  • map, flatMap - Handle both sync and async transformation functions
  • all, race, traverse - Combine multiple async operations

Core Concepts

AsyncOk and AsyncErr

AsyncResult<T, E> has two possible states:

StateDescriptionProperties
AsyncOk<T>Async successok: true, value: T
AsyncErr<E>Async failureok: false, error: E
import { okAsync, errAsync } from '@deessejs/fp';

// Success
const success = await okAsync({ id: 1, name: 'John' });
// { ok: true, value: { id: 1, name: 'John' } }

// Failure
const failure = await errAsync({ code: 'NOT_FOUND', message: 'User not found' });
// { ok: false, error: { code: 'NOT_FOUND', message: 'User not found' } }

Thenable Interface

AsyncResult implements the Thenable pattern, allowing direct await and chaining:

import { fromPromise } from '@deessejs/fp';

// Direct await
const result = await fromPromise(fetch('/api/user'));

// Thenable chaining without intermediate await
fromPromise(fetch('/api/user'))
  .then(result => result.ok ? result.value : null)
  .catch(error => null);

Error Wrapping

When using fromPromise, rejections are automatically wrapped:

  • Error objects are wrapped in PanicError with the original error as cause
  • Non-Error values are stringified and wrapped in PanicError
// String rejections are wrapped
fromPromise(Promise.reject('oops'));
// AsyncErr(PanicError({ message: 'oops' }))

// Error rejections are preserved as cause
fromPromise(Promise.reject(new Error('failed')));
// AsyncErr(PanicError({ message: 'failed' }).from(originalError))

Reference

Creating AsyncResults

ok(value) / okAsync(value)

Creates an async success result.

import { ok, okAsync } from '@deessejs/fp';

ok(42);        // AsyncOk(42)
okAsync('hello'); // AsyncOk('hello')

err(error) / errAsync(error)

Creates an async error result.

import { err, errAsync } from '@deessejs/fp';

err('Something went wrong');           // AsyncErr('Something went wrong')
errAsync({ code: 'NOT_FOUND' });      // AsyncErr({ code: 'NOT_FOUND' })

fromPromise(promise)

Wraps a Promise. Resolves to AsyncOk if the promise succeeds, AsyncErr if it rejects.

import { fromPromise } from '@deessejs/fp';

const result = await fromPromise(fetch('/api/user'));

fromPromise(promise, onError)

Wraps a Promise with a custom error handler.

import { fromPromise, error } from '@deessejs/fp';
import { z } from 'zod';

const NetworkError = error({
  name: 'NetworkError',
  schema: z.object({ url: z.string() }),
  message: (args) => `Failed to fetch: ${args.url}`,
});

const result = fromPromise(
  fetch('/api/user'),
  (err) => NetworkError({ url: '/api/user' })
);

fromPromise(promise, { signal })

Wraps a Promise with AbortSignal support.

import { fromPromise } from '@deessejs/fp';

const controller = new AbortController();
const result = fromPromise(
  fetch('/api/user', { signal: controller.signal }),
  { signal: controller.signal }
);

fromValue(value, ms)

Creates an AsyncResult that resolves after a delay.

import { fromValue } from '@deessejs/fp';

fromValue(42, 1000); // Resolves to AsyncOk(42) after 1 second

fromError(error, ms)

Creates an AsyncResult that rejects after a delay.

import { fromError } from '@deessejs/fp';

fromError({ code: 'TIMEOUT' }, 1000); // Resolves to AsyncErr({ code: 'TIMEOUT' }) after 1 second

Type Guards

isOk(result)

Type guard that narrows to the AsyncOk branch.

import { fromPromise, isOk } from '@deessejs/fp';

const result = await fromPromise(Promise.resolve(42));

if (isOk(result)) {
  console.log(result.value); // 42
}

isErr(result)

Type guard that narrows to the AsyncErr branch.

import { fromPromise, isErr } from '@deessejs/fp';

const result = await fromPromise(Promise.reject('oops'));

if (isErr(result)) {
  console.log(result.error); // 'oops'
}

isAbortError(error)

Checks if an error is an AbortError.

import { isAbortError } from '@deessejs/fp';

if (isAbortError(error)) {
  console.log('Operation was aborted');
}

Transformation

map(result, fn)

Transforms the value if AsyncOk. Handles both sync and async functions. Passes errors through unchanged.

import { fromPromise, map } from '@deessejs/fp';

// Sync transformation
map(ok(2), x => x * 2); // AsyncOk(4)

// Async transformation (map handles async functions automatically)
map(ok(2), async x => {
  const data = await fetchData(x);
  return data.value * 2;
});

Also available as a method:

ok(2).map(x => x * 2); // AsyncOk(4)

flatMap(result, fn)

Chains AsyncResults. If AsyncOk, applies the function. If AsyncErr, returns AsyncErr. Handles both sync and async functions.

import { ok, flatMap } from '@deessejs/fp';

const fetchUser = (id: number) =>
  id > 0 ? ok({ id, name: 'John' }) : err('Invalid id');

flatMap(ok(1), fetchUser);  // AsyncOk({ id: 1, name: 'John' })
flatMap(ok(-1), fetchUser); // AsyncErr('Invalid id')

Also available as a method:

ok(1).flatMap(fetchUser); // AsyncOk({ id: 1, name: 'John' })

mapErr(result, fn)

Transforms the error. Passes success through unchanged.

import { err, mapErr } from '@deessejs/fp';

mapErr(err('not found'), e => ({ ...e, code: 'NOT_FOUND' }));
// AsyncErr({ ...original error, code: 'NOT_FOUND' })

Also available as a method:

err('not found').mapErr(e => ({ ...e, code: 'NOT_FOUND' }));

Extraction

getOrElse(result, defaultValue)

Returns the value if AsyncOk, or a default if AsyncErr.

import { ok, err, getOrElse } from '@deessejs/fp';

getOrElse(ok(42), 0);  // 42
getOrElse(err('oops'), 0); // 0

Also available as a method:

ok(42).getOrElse(0); // 42

getOrCompute(result, fn)

Returns the value if AsyncOk, or computes one lazily if AsyncErr.

import { ok, err, getOrCompute } from '@deessejs/fp';

const expensive = () => { console.log('computing...'); return 0; };

getOrCompute(ok(42), expensive); // 42 (never logs)
getOrCompute(err('oops'), expensive); // logs 'computing...', returns 0

Also available as a method:

err('oops').getOrCompute(() => 0); // 0

unwrap(result)

Extracts the value if AsyncOk, throws the error if AsyncErr.

import { ok, err, unwrap } from '@deessejs/fp';

unwrap(ok(42));      // 42
unwrap(err('oops')); // throws 'oops'

Also available as a method:

ok(42).unwrap(); // 42

unwrapOr(result, defaultValue)

Extracts the value if AsyncOk, returns default if AsyncErr.

import { ok, err, unwrapOr } from '@deessejs/fp';

unwrapOr(ok(42), 0);  // 42
unwrapOr(err('oops'), 0); // 0

Side Effects

tap(result, fn)

Executes a function on the value without changing it. Useful for logging.

import { ok, tap } from '@deessejs/fp';

tap(ok(42), x => console.log('Got:', x)); // Logs: 'Got: 42', returns AsyncOk(42)
tap(err('oops'), x => console.log('Got:', x)); // Nothing logged, returns AsyncErr('oops')

Also available as a method:

ok(42).tap(x => console.log(x)); // AsyncOk(42)

tapErr(result, fn)

Executes a function on the error without changing it.

import { err, tapErr } from '@deessejs/fp';

tapErr(ok(42), e => console.error('Error:', e));                 // Nothing logged
tapErr(err('oops'), e => console.error('Error:', e));            // Logs: 'Error: oops'

Also available as a method:

err('oops').tapErr(e => console.error(e)); // AsyncErr('oops')

Pattern Matching

match(result, okFn, errFn)

Handles both AsyncOk and AsyncErr cases, returning a single Promise value.

import { ok, match } from '@deessejs/fp';

match(
  ok(42),
  value => `Success: ${value}`,
  error => `Failed: ${error}`
); // Promise<'Success: 42'>

Conversions

toNullable(result)

Converts to Promise<T | null>.

import { ok, err, toNullable } from '@deessejs/fp';

toNullable(ok(42));   // Promise<42>
toNullable(err('x')); // Promise<null>

toUndefined(result)

Converts to Promise<T | undefined>.

import { ok, err, toUndefined } from '@deessejs/fp';

toUndefined(ok(42));   // Promise<42>
toUndefined(err('x')); // Promise<undefined>

Combination

all(...results)

Runs multiple AsyncResults in parallel. Returns AsyncOk with array of values if all succeed, or the first AsyncErr (fail-fast).

import { fromPromise, all } from '@deessejs/fp';

all(
  fromPromise(fetch('/api/user/1')),
  fromPromise(fetch('/api/user/2')),
  fromPromise(fetch('/api/user/3'))
); // AsyncOk([user1, user2, user3]) or AsyncErr(firstError)

allSettled(...results)

Runs multiple AsyncResults in parallel. Returns AsyncOk with tuple of values and errors, regardless of success or failure.

import { fromPromise, allSettled } from '@deessejs/fp';

allSettled(
  fromPromise(fetch('/api/user/1')),
  fromPromise(fetch('/api/user/2'))
); // AsyncOk({ values: [user1, user2], errors: [] })
// or if both fail: AsyncOk({ values: [], errors: [err1, err2] })

race(...results)

Returns the value of the first AsyncResult to resolve. Throws if the first one rejects.

import { fromPromise, race, errAsync } from '@deessejs/fp';

race(
  fromPromise(fetch('/api/fast')),
  fromPromise(fetch('/api/slow'))
); // Resolves to the fastest response, or throws if first rejects

traverse(items, fn)

Maps over items in parallel, running an async function for each.

import { fromPromise, traverse } from '@deessejs/fp';

const ids = [1, 2, 3];

traverse(ids, id =>
  fromPromise(fetch(`/api/users/${id}`).then(r => r.json()))
); // AsyncOk([user1, user2, user3]) or AsyncErr(firstError)

Signal Handling

withSignal(result, signal)

Wraps an AsyncResult to abort when the AbortSignal is triggered.

import { fromPromise, withSignal } from '@deessejs/fp';

const controller = new AbortController();

const result = withSignal(
  fromPromise(fetch('/api/user', { signal: controller.signal })),
  controller.signal
);

// To abort:
controller.abort();

Method Chaining

AsyncResult supports fluent chaining through its Thenable interface:

import { fromPromise } from '@deessejs/fp';

const result = await fromPromise(fetchUser(id))
  .map(user => ({ ...user, fullName: user.name.toUpperCase() }))
  .flatMap(user => fromPromise(saveUser(user)))
  .tap(user => console.log('Saved:', user))
  .mapErr(error => ({ ...error, timestamp: Date.now() }));

Real-World Examples

Chained API Calls

import { fromPromise, ok, flatMap, isOk } from '@deessejs/fp';

interface User { id: number; name: string; }
interface Post { id: number; title: string; }

const fetchUser = (id: number) =>
  fromPromise(
    fetch(`/api/users/${id}`).then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json() as Promise<User>;
    })
  );

const fetchPosts = (userId: number) =>
  fromPromise(
    fetch(`/api/users/${userId}/posts`).then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json() as Promise<Post[]>;
    })
  );

// Chain them
const getUserWithPosts = (id: number) =>
  flatMap(fetchUser(id), user =>
    flatMap(fetchPosts(user.id), posts =>
      ok({ user, posts })
    )
  );

const result = await getUserWithPosts(1);

if (isOk(result)) {
  console.log(result.value.user.name, result.value.posts.length);
}

Parallel Data Fetching

import { fromPromise, all, traverse } from '@deessejs/fp';

const fetchDashboard = async (userId: number) => {
  const [user, stats, notifications] = await all(
    fromPromise(fetch(`/api/users/${userId}`).then(r => r.json())),
    fromPromise(fetch(`/api/users/${userId}/stats`).then(r => r.json())),
    fromPromise(fetch(`/api/users/${userId}/notifications`).then(r => r.json()))
  );

  return { user, stats, notifications };
};

const processItems = async (ids: number[]) => {
  const results = await traverse(ids, async id => {
    const data = await fetchItem(id);
    return transformItem(data);
  });
  return results;
};

Error Recovery

import { fromPromise, ok, err, isOk } from '@deessejs/fp';

const fetchWithFallback = async (primaryUrl: string, fallbackUrl: string) => {
  const primary = await fromPromise(fetch(primaryUrl));

  if (isOk(primary)) {
    return ok(await primary.value.json());
  }

  const fallback = await fromPromise(fetch(fallbackUrl));

  if (isOk(fallback)) {
    return ok(await fallback.value.json());
  }

  return err(new Error('Both primary and fallback failed'));
};

AsyncResult vs Result vs Try

AspectAsyncResultResultTry
OperationsAsyncSyncSync
Error handlingExplicit errorsExplicit errorsCatches exceptions
APIThenableDirectDirect
CancellationAbortSignalN/AN/A
// AsyncResult: async operations with explicit errors
const user = await fromPromise(fetch('/api/user'));

// Result: sync operations with explicit errors
const parsed = parseUser(input); // Result<User, ParseError>

// Try: sync operations that might throw
const parsed = attempt(() => JSON.parse(input));

See Also

  • Result - For synchronous operations with explicit error types
  • Maybe - For optional values (null/undefined)
  • Try - For wrapping sync functions that might throw
  • Error - For structured errors with enrichment

On this page