Skip to content

awaitly vs neverthrow

Both awaitly and neverthrow provide Result types for TypeScript. This guide compares their APIs and helps you choose the right tool.

neverthrow gives you a way to represent success or failure. It’s a data type: Result<T, E>, combinators, and explicit error values. It doesn’t structure your app, provide dependency injection, or model effectful computation. It’s a tool for wrapping results.

awaitly gives you a way to model computations that:

  • run asynchronously
  • depend on an explicit environment (deps)
  • may fail with a typed error
  • compose predictably (workflows, steps, retries, persistence)

neverthrow wraps results; awaitly models effects. neverthrow is a good fit for local operations and clear error returns. awaitly takes on application-level composition and environment, so as your app grows you keep one consistent way to handle async, dependencies, and errors together.

Feature awaitly neverthrow
Result type Result<T, E> Result<T, E>
Async result AsyncResult<T, E> ResultAsync<T, E>
Method style Functions Methods
Retry per-dep retry() policy Not included
Result serialization deserialize() (typed errors) Not included
Flatten nested Results flatten() Not included
Workflow orchestration Built-in Not included
Workflow error inference from deps Yes No
Step IDs + events / tracing Yes No
Concern neverthrow awaitly
Typed errors
Async composition ⚠️ manual (ResultAsync, safeTry, etc.) ✅ built-in (workflows, steps)
Dependency injection ✅ (deps at creation or per run)
Unified abstraction (async + env + errors)
Runtime / framework

neverthrow is the smallest step from plain async/await if you only want Result types. awaitly is a bigger step: one model for composition, environment, and errors, in a minimal effect style for TypeScript apps.

import { ok, err } from 'awaitly';
const divide = (a: number, b: number) =>
b === 0 ? err('DIVIDE_BY_ZERO') : ok(a / b);
const result = divide(10, 2);
/*
Output:
{ ok: true, value: 5 }
*/
import { isOk } from 'awaitly';
const result = divide(10, 2);
// Property-based
if (result.ok) {
console.log(result.value); // 5
}
// Function-based
if (isOk(result)) {
console.log(result.value); // 5
}
import { ok, map } from 'awaitly';
const result = ok(5);
const doubled = map(result, n => n * 2);
/*
Output:
{ ok: true, value: 10 }
*/
import { err, mapError } from 'awaitly';
const result = err('NOT_FOUND');
const mapped = mapError(result, e => ({ code: e, status: 404 }));
/*
Output:
{ ok: false, error: { code: 'NOT_FOUND', status: 404 } }
*/

awaitly deliberately ships no pipe. Short chains are plain function calls with the named combinators:

import { ok, map, mapError } from 'awaitly';
const doubled = map(ok(5), (n) => n * 2);
const result = mapError(doubled, (e) => e.toUpperCase());
// { ok: true, value: 10 }

For anything longer, especially async, the answer is not a pipeline at all. Plain async/await with run() gives sequencing, short-circuiting, and inferred error types without a second dialect (see Generator-Based Composition below).

import { ok, err, andThen } from 'awaitly';
const parseNumber = (s: string) => {
const n = parseInt(s, 10);
return isNaN(n) ? err('PARSE_ERROR') : ok(n);
};
const result = ok('42');
const parsed = andThen(result, parseNumber);
/*
Output:
{ ok: true, value: 42 }
*/
import { ok, match } from 'awaitly';
const result = ok(42);
const message = match(result, {
ok: value => `Success: ${value}`,
err: error => `Error: ${error}`,
});
/*
Output:
"Success: 42"
*/
import { ok, err, type AsyncResult } from 'awaitly';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => {
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
};
// Use with regular async/await
const result = await fetchUser('123');
if (result.ok) {
console.log(result.value.name);
}
import { ok, err, all } from 'awaitly';
const results = [ok(1), ok(2), ok(3)];
const combined = all(results);
/*
Output:
{ ok: true, value: [1, 2, 3] }
*/
const withError = [ok(1), err('FAILED'), ok(3)];
const failed = all(withError);
/*
Output:
{ ok: false, error: 'FAILED' }
*/
import { ok, unwrap, unwrapOr, unwrapOrElse } from 'awaitly';
const result = ok(42);
// Throws if err
const value1 = unwrap(result); // 42
// Default value (does not throw)
const value2 = unwrapOr(result, 0); // 42
// Computed default
const value3 = unwrapOrElse(result, err => {
console.log('Failed:', err);
return 0;
}); // 42

Chain a sync Result into an async operation.

import { ok, err, run, type AsyncResult } from 'awaitly';
const parseId = (s: string) => {
const n = parseInt(s, 10);
return isNaN(n) ? err('PARSE_ERROR') : ok(n);
};
const fetchUser = async (id: number): AsyncResult<User, 'NOT_FOUND'> => {
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
};
// Sync and async deps mix freely — await handles both
const result = await run({ parseId, fetchUser }, async (s) => {
const id = await s.parseId('42');
return s.fetchUser(id);
});
/*
Output:
{ ok: true, value: { id: 42, name: 'Alice' } }
result.error: 'PARSE_ERROR' | 'NOT_FOUND' | UnexpectedError — inferred
*/

Transform the success value with an async function.

import { ok } from 'awaitly';
const result = ok(42);
// Plain async/await — no dedicated asyncMap combinator needed
const enriched = result.ok
? ok({ value: result.value, metadata: await fetchMetadata(result.value) })
: result;
/*
Output:
{ ok: true, value: { value: 42, metadata: {...} } }
*/

Provide a fallback Result on failure.

import { ok, err, orElse } from 'awaitly';
const result = err('NOT_FOUND');
const recovered = orElse(result, (e) => ok({ fallback: true, reason: e }));
/*
Output:
{ ok: true, value: { fallback: true, reason: 'NOT_FOUND' } }
*/
// Can also return a different error
const retyped = orElse(result, () => err('FALLBACK_FAILED'));
/*
Output:
{ ok: false, error: 'FALLBACK_FAILED' }
*/

Collect ALL errors instead of failing on the first one.

import { ok, err, all, allSettled } from 'awaitly';
const results = [ok(1), err('ERROR_A'), ok(3), err('ERROR_B')];
const settled = allSettled(results);
/*
Output:
{ ok: false, error: [{ error: 'ERROR_A' }, { error: 'ERROR_B' }] }
*/
// For fail-fast behavior, use all() instead
const failFast = all(results);
/*
Output:
{ ok: false, error: 'ERROR_A' }
*/

Safely wrap functions that might throw exceptions.

import { from, fromPromise } from 'awaitly';
// Sync: from(fn, onError)
const parseJson = (s: string) =>
from(
() => JSON.parse(s),
(e) => ({ type: 'PARSE_ERROR' as const, message: String(e) })
);
// Async (outside workflows): fromPromise(promise, onError)
const fetchSafe = (url: string) =>
fromPromise(fetch(url).then(r => r.json()), () => 'FETCH_ERROR' as const);
// Inside workflows: step.try('id', () => …, { error: 'MY_ERROR' })
const valid = parseJson('{"name": "Alice"}');
const invalid = parseJson('not json');

neverthrow provides safeTry for generator-based composition. awaitly uses standard async/await instead.

import { run } from 'awaitly';
// awaitly uses familiar async/await — no generators needed
const result = await run({ parseId, fetchUser, sendEmail }, async (s) => {
const id = await s.parseId('42');
const user = await s.fetchUser(id);
await s.sendEmail(user.email);
return user;
});
// Errors short-circuit automatically, no special syntax required,
// and result.error is inferred from the deps

With run(), pass your deps as the first argument. The error union is inferred, with no type parameters needed:

import { run, type AsyncResult } from 'awaitly';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const sendEmail = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
const chargeCard = async (amount: number): AsyncResult<Receipt, 'PAYMENT_DECLINED'> => { /* ... */ };
const result = await run({ fetchUser, sendEmail, chargeCard }, async (s) => {
const user = await s.fetchUser('123');
await s.chargeCard(99.99);
await s.sendEmail(user.email);
return user;
});
// result.error is: 'NOT_FOUND' | 'EMAIL_FAILED' | 'PAYMENT_DECLINED' | UnexpectedError

With createWorkflow, error inference is automatic, no ErrorsOf needed:

import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('workflow', { fetchUser, sendEmail, chargeCard });
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('123'));
await step('chargeCard', () => deps.chargeCard(99.99));
await step('sendEmail', () => deps.sendEmail(user.email));
return user;
});
// Same error type, automatically inferred from dependencies

Inside workflows, step is the workhorse. Chaining is calling step again with the success value. Pattern matching is plain JS branching after the step returns.

const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('123'));
const enriched = await step('enrich', () => deps.enrichUser(user));
return enriched.displayName;
});
const result = await workflow.run(async ({ step, deps }) => {
// Retry with exponential backoff
const user = await step.retry(
'fetchUser',
() => deps.fetchUser('123'),
{ attempts: 3, backoff: 'exponential', initialDelay: 100 }
);
// Timeout protection
const data = await step.withTimeout(
'slowOp',
() => deps.slowOperation(),
{ ms: 5000 }
);
return { user, data };
});
// Option 1: In-memory (simple)
const workflow = createWorkflow('workflow', deps, {
cache: new Map(),
resumeState: savedState, // Resume from previous run
});
// Option 2: Store (awaitly-mongo or awaitly-postgres) — runWithState + save/loadResumeState
import { mongo } from 'awaitly-mongo';
// or: import { postgres } from 'awaitly-postgres';
const store = mongo(process.env.MONGODB_URI!);
const { result, resumeState } = await workflow.runWithState(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });
return user;
});
await store.save('wf-1', resumeState);
// Restore
const loaded = await store.loadResumeState('wf-1');
if (loaded) {
await workflow.run(async ({ step, deps }) => { /* same fn */ }, { resumeState: loaded });
}
  • You want Result types with familiar async/await syntax
  • You need workflow orchestration (retries, timeouts, caching)
  • You want automatic error type inference
  • You’re building multi-step async operations
  • You need step-level resilience patterns
  • You want to keep async/await ergonomics for multi-step flows
  • You only need Result types without workflow features
  • You prefer method chaining over function calls
  • You want the smallest possible bundle size
  • Your project already uses neverthrow

neverthrow helps you return errors. awaitly helps you structure whole applications around them.

// neverthrow: method chaining
import { ok } from 'neverthrow';
const result = ok(5).map(n => n * 2).mapErr(e => e.toUpperCase());
// awaitly: standalone functions (no special helpers required)
import { ok, map, mapError } from 'awaitly';
const base = ok(5);
const doubled = map(base, (n) => n * 2);
const result = mapError(doubled, (e) => e.toUpperCase());

There is no pipeline operator to learn: intermediate values are ordinary const bindings, and multi-step async flows use run() (see No pipeline operator (awaitly) above).