Skip to content

Result Types

Every function that can fail has to answer one question: how does it tell you what went wrong? try/catch hides the answer: the function throws, and you find out at runtime. A Result puts the answer in the return value instead.

A Result is a plain object with one of two shapes:

// success
{ ok: true, value: 5 }
// failure
{ ok: false, error: 'DIVIDE_BY_ZERO' }

You check result.ok to see which one you got. There’s nothing magic here. You could write these objects by hand. awaitly gives you ok() and err() to build them, plus helpers to work with them safely.

You’ll see types like Result<number, 'DIVIDE_BY_ZERO'> throughout this page. If TypeScript’s notation is new to you, three pieces are worth knowing first:

  • A specific string can be a type. A value typed as 'DIVIDE_BY_ZERO' can only ever be that exact string. The string is both a value and its own type.
  • | means “one of”. 'A' | 'B' describes a value that is either 'A' or 'B', and nothing else. TypeScript calls this a union.
  • The angle brackets fill in blanks. Result needs two types to be complete: the success value, then the error. You pass them in that order, so Result<number, 'DIVIDE_BY_ZERO'> reads as “a number if it worked, or 'DIVIDE_BY_ZERO' if it didn’t.”

Take your time with these. The rest of the page builds on them.

// fetchUser returns AsyncResult<User, 'NOT_FOUND'>
const result = await fetchUser('123');
if (result.ok) {
console.log(result.value.name);
} else {
// TypeScript knows result.error is 'NOT_FOUND'
console.log(`User ${result.error}`);
}

Results give you:

  • Type safety: TypeScript knows exactly what errors can occur
  • Explicit handling: Errors are part of the return type, not hidden
  • Composability: Results chain together naturally
┌─── The success value type
│ ┌─── The error type
▼ ▼
Result<Value, Error>
Examples:
Result<number, 'DIVIDE_BY_ZERO'> → number on success, 'DIVIDE_BY_ZERO' on failure
Result<User, 'NOT_FOUND'> → User on success, 'NOT_FOUND' on failure
Result<Order, 'INVALID' | 'EXPIRED'> → Order on success, one of two errors on failure
import { ok, err, type AsyncResult } from 'awaitly';
// Synchronous
const divide = (a: number, b: number): Result<number, 'DIVIDE_BY_ZERO'> =>
b === 0 ? err('DIVIDE_BY_ZERO') : ok(a / b);
// Asynchronous
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => {
const user = await db.users.find(id);
return user ? ok(user) : err('NOT_FOUND');
};

Check result.ok to determine success or failure:

const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // 5
} else {
console.log(result.error); // TypeScript knows this is 'DIVIDE_BY_ZERO'
}

After checking result.ok, TypeScript narrows the type:

  • If result.ok is true, you can access result.value
  • If result.ok is false, you can access result.error

Use isOk and isErr for functional-style checks:

import { isOk, isErr } from 'awaitly';
const result = divide(10, 0);
if (isOk(result)) {
console.log(result.value);
}
if (isErr(result)) {
console.log(result.error); // 'DIVIDE_BY_ZERO'
}

Transform the success value, leaving errors unchanged:

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

Transform the error, leaving success values unchanged:

import { mapError } from 'awaitly';
const result = err('NOT_FOUND');
const mapped = mapError(result, (e) => ({ type: e, status: 404 }));
// { ok: false, error: { type: 'NOT_FOUND', status: 404 } }

Chain operations that might fail. The function only runs if the previous result was ok:

import { andThen } from 'awaitly';
const result = ok(10);
const chained = andThen(result, (n) =>
n > 0 ? ok(n * 2) : err('NEGATIVE')
);
// { ok: true, value: 20 }
const negative = ok(-5);
const failed = andThen(negative, (n) =>
n > 0 ? ok(n * 2) : err('NEGATIVE')
);
// { ok: false, error: 'NEGATIVE' }

Handle both cases in one expression. The discriminant is type everywhere, string errors match themselves, tagged objects match on type, and TaggedError instances expose .type as the canonical field (._tag remains as a deprecated alias).

Two-arm catch-all:

import { match } from 'awaitly';
const result = divide(10, 2);
const message = match(result, {
ok: (value) => `Success: ${value}`,
err: (error) => `Error: ${error}`,
});
// "Success: 5"

Exhaustive per-type arms: dispatch across string literals, tagged objects, and TaggedError classes in one call:

import { match, ok, err, type Result } from 'awaitly';
type FetchUserError = 'USER_NOT_FOUND' | TimeoutError | UnexpectedError;
// `await` unwraps the AsyncResult, so the value here is a `Result`.
const result: Result<User, FetchUserError> = await fetchUser('123');
const response = match(result, {
ok: (user) => ({ status: 200, body: user }),
USER_NOT_FOUND: () => ({ status: 404, body: { error: 'not_found' } }),
TimeoutError: (e) => ({ status: 504, body: { error: 'timeout', ms: e.ms } }),
UnexpectedError: (e) => ({ status: 500, body: { error: 'internal', cause: e.cause } }),
});

TypeScript errors if you omit a variant from the inferred union. See Tagged Errors for TaggedError-specific helpers.

import { unwrap } from 'awaitly';
const result = divide(10, 2);
const value = unwrap(result); // 5
const badResult = divide(10, 0);
const boom = unwrap(badResult); // Throws!

Return a default value on error:

import { unwrapOr } from 'awaitly';
const result = divide(10, 0);
const value = unwrapOr(result, 0); // 0

unwrapOrElse - Get Value or Compute Default

Section titled “unwrapOrElse - Get Value or Compute Default”

Compute the default from the error:

import { unwrapOrElse } from 'awaitly';
const result = divide(10, 0);
const value = unwrapOrElse(result, (error) => {
console.log('Failed with:', error);
return 0;
});
// Logs: Failed with: DIVIDE_BY_ZERO
// Returns: 0

Convert exceptions into Results:

import { from } from 'awaitly';
const result = from(() => JSON.parse('{"valid": true}'));
// { ok: true, value: { valid: true } }
const invalid = from(() => JSON.parse('not json'));
// { ok: false, error: SyntaxError }
import { fromPromise } from 'awaitly';
const result = await fromPromise(fetch('/api/data'));
// Success: { ok: true, value: Response }
// Network error: { ok: false, error: TypeError }

Convert exceptions into typed errors:

import { tryAsync } from 'awaitly';
const result = await tryAsync(
() => fetch('/api/data').then(r => r.json()),
(thrown) => ({ type: 'FETCH_FAILED', cause: thrown })
);
// Success: { ok: true, value: { ...data } }
// Failure: { ok: false, error: { type: 'FETCH_FAILED', cause: Error } }

Returns the first error encountered, or all values if everything succeeds:

import { all } from 'awaitly';
const results = [ok(1), ok(2), ok(3)];
const combined = all(results);
// { ok: true, value: [1, 2, 3] }
const withError = [ok(1), err('FAILED'), ok(3)];
const failed = all(withError);
// { ok: false, error: 'FAILED' }

Combining operations typed as Result<void, E> produces Result<void[], E>. The ts-eslint strict preset rejects void[] through @typescript-eslint/no-invalid-void-type. Use undefined for functions that return no value when you plan to combine their Results:

declare const revoke: (id: string) => Result<undefined, Forbidden>;
const revoked: Result<undefined[], Forbidden> = all(ids.map(revoke));

Returns Ok with every value when all succeed, or Err with an array of { error, cause? } entries when any fail:

import { allSettled } from 'awaitly';
const allOk = [ok(1), ok(2), ok(3)];
const settled = allSettled(allOk);
// { ok: true, value: [1, 2, 3] }
const mixed = [ok(1), err('A'), ok(3), err('B')];
const failed = allSettled(mixed);
// {
// ok: false,
// error: [
// { error: 'A' },
// { error: 'B' },
// ]
// }

partition - Separate Successes and Failures

Section titled “partition - Separate Successes and Failures”

Returns a { values, errors } object, never fails:

import { partition } from 'awaitly';
const results = [ok(1), err('A'), ok(3)];
const { values, errors } = partition(results);
// values: [1, 3]
// errors: ['A']

Most operations have async variants for working with promises:

import { allAsync, anyAsync } from 'awaitly';
const results = await allAsync([
fetchUser('1'),
fetchPosts('1'),
fetchComments('1'),
]);
// All succeed: { ok: true, value: [User, Post[], Comment[]] }
// One fails: { ok: false, error: 'NOT_FOUND' }

Unwrap nested Results:

import { flatten, ok, err } from 'awaitly/result';
const nested = ok(ok(42));
const flat = flatten(nested);
// { ok: true, value: 42 }
const nestedErr = ok(err('INNER'));
const flat2 = flatten(nestedErr);
// { ok: false, error: 'INNER' }

Rehydrate Results from JSON, RPC, or server actions with type-safe error handling:

import { deserialize, DESERIALIZATION_ERROR } from 'awaitly/result';
// Valid serialized Result
const result = deserialize<User, AppError>(rpcResponse);
if (result.ok) {
console.log(result.value); // User
}
// Invalid input returns a typed DeserializationError
const invalid = deserialize({ foo: 'bar' });
if (!invalid.ok && invalid.error.type === DESERIALIZATION_ERROR) {
console.log('Bad input:', invalid.error.value);
}

Wrap SDK/vendor edges with tryAsync so error classification lives at the boundary, then add the retry policy where the function is declared so classification and retry policy stay together:

import { tryAsync, retry } from 'awaitly';
const authorize = () =>
tryAsync(
() => paymentProvider.authorize(card, total),
(cause) =>
isTimeoutAfterCapture(cause)
? new PaymentLimbo({ attemptId, cause })
: new TransientVendorError({ vendor: 'stripe', cause })
);
const result = await retry(authorize, {
attempts: 4,
delay: 100,
retryIf: (e) => e instanceof TransientVendorError,
})();
// 1) Timeout-after-capture: classify as do-not-retry
const authorizePayment = () =>
tryAsync(
() => gateway.authorize(card, total),
(cause) =>
isTimeoutAfterCapture(cause)
? new PaymentLimbo({ attemptId, cause })
: new TransientVendorError({ vendor: 'stripe', cause })
);
const payment = await retry(authorizePayment, {
attempts: 3,
delay: 100,
backoff: 'fixed',
retryIf: (e) => e instanceof TransientVendorError,
})();
// 2) Validation boundary mapping
const parsed = await tryAsync(
() => Promise.resolve(schema.parse(input)),
(cause) => new ValidationError({ field: 'input', reason: String(cause) })
);

Retry async operations without the full workflow engine. retry wraps any Result-returning (or plain) function and keeps its error union unchanged, if all attempts fail, the last failure propagates:

import { tryAsync, retry } from 'awaitly';
const fetchData = () =>
tryAsync(
() => fetch('/api/data').then(r => r.json()),
(cause) => ({ type: 'FETCH_FAILED', cause })
);
const result = await retry(fetchData, {
attempts: 4,
delay: 100,
backoff: 'exponential',
})();

By default, retry skips UnexpectedError and untagged throws. Typed errors still retry. Pass retryIf: () => true to opt back into retrying throws. In tests, pass createTestClock() as clock so delays do not wait on real time. Policy wrappers do not pick up run({ clock }).

The same wrapped function drops straight into a run() deps object, so the policy is visible where the dependency is declared.

Bound a dependency’s execution time. On timeout, resolves to err(TimeoutError), adding TimeoutError to the error union:

import { tryAsync, timeout } from 'awaitly';
const fetchData = () =>
tryAsync(
() => fetch('/api/data').then(r => r.json()),
(cause) => ({ type: 'FETCH_FAILED', cause })
);
const result = await timeout(fetchData, 5000)();
// result.error may be 'FETCH_FAILED' | TimeoutError

Accepts milliseconds or duration strings ('5s', '500ms'). Optional third argument { clock } uses that clock for the deadline instead of wall time.

Recover from a dependency’s failure. The handler receives the failure plus original arguments; base errors are consumed and only handler errors remain in the union:

import { ok, fallback, tryAsync } from 'awaitly';
const fetchPrimary = () =>
tryAsync(
() => primaryCache.get(key),
() => ({ type: 'CACHE_MISS' })
);
const result = await fallback(fetchPrimary, () => ok({ value: 'default' }))();
// On CACHE_MISS, returns ok({ value: 'default' }) — no typed errors left

Policies nest inside-out. Timeout applies per attempt when wrapped by retry:

import { retry, timeout } from 'awaitly';
const deps = {
charge: retry(timeout(charge, 5000), {
attempts: 3,
backoff: 'exponential',
delay: 100,
}),
};

awaitly-analyze reads policy chains from the deps literal and records them on DependencyInfo.policies for diagrams and diagnostics.

Function Purpose
ok(value) Create success Result
err(error) Create failure Result
map(result, fn) Transform success value
mapError(result, fn) Transform error
andThen(result, fn) Chain operations
match(result, { ok, err }) Two-arm pattern match
match(result, { ok, Variant, ... }) Exhaustive per-type match
unwrap(result) Get value or throw
unwrapOr(result, default) Get value or default
from(fn) Wrap sync throwing code
fromPromise(promise) Wrap async throwing code
tryAsync(fn, mapError) Wrap with custom error
retry(fn, options) Per-dep retry policy (also works standalone)
timeout(fn, ms) Per-dep timeout policy; adds TimeoutError
fallback(fn, handler) Per-dep fallback; consumes base errors
all(results) Combine, fail on first error
partition(results) Separate successes/failures
flatten(nested) Unwrap nested Results
deserialize(value) Rehydrate from JSON/RPC

Learn about Workflows and Steps →