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:

import { match } from 'awaitly';
const result = divide(10, 2);
const message = match(result, {
ok: (value) => `Success: ${value}`,
err: (error) => `Error: ${error}`,
});
// "Success: 5"
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' as const, 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' }

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);
}

Use object-style config when wrapping SDK/vendor edges so classification and retry policy stay together:

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

Retry async operations without the full workflow engine:

import { tryAsyncRetry } from 'awaitly/result/retry';
const result = await tryAsyncRetry(
() => fetch('/api/data').then(r => r.json()),
(cause) => ({ type: 'FETCH_FAILED' as const, cause }),
{
retry: {
attempts: 4,
initialDelay: 100,
backoff: 'exponential',
shouldRetry: (e) => e.type === 'FETCH_FAILED',
},
}
);
FunctionPurpose
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 })Pattern 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
tryAsyncBoundary({ try, catch, retry? })Edge-first async wrapper with optional retry policy
tryAsyncRetry(fn, config)Wrap with retry support
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 →