API reference@evolu/common › Result

Type-safe error handling with Result.

The problem with exceptions in JavaScript is that a caught value is always of unknown type. We can't be sure all errors have been handled because the TypeScript compiler can't tell us what might be thrown — we can't use exhaustiveCheck.

Languages like Rust and Haskell model recoverable failures with types such as Result and Either, making errors part of the return type.

TypeScript can express the same pattern with discriminated unions of plain objects. In Evolu, domain error interfaces extend Typed to avoid repeating the literal type discriminant.

import {
  assertEqual,
  assertErr,
  assertType,
  err,
  exhaustiveCheck,
  type Result,
  type Typed,
} from "@evolu/common";

// TypeScript can't know what was thrown.
try {
  throw new Error("Not found");
} catch (error) {
  assertType<unknown, typeof error>();
}

// With Result, errors are part of the return type.
const doSomething = (): Result<number, InvalidInputError | NotFoundError> =>
  err({ type: "NotFound" });

interface InvalidInputError extends Typed<"InvalidInput"> {}

interface NotFoundError extends Typed<"NotFound"> {}

// With Result, the error type is known and exhaustiveCheck works.
// If we add another error type, TypeScript tells us it isn't handled.
const result = doSomething();
if (!result.ok) {
  switch (result.error.type) {
    case "NotFound":
      assertEqual(result.error, { type: "NotFound" });
      break;
    case "InvalidInput":
      assertEqual(result.error, { type: "InvalidInput" });
      break;
    default:
      exhaustiveCheck(result.error);
  }
}
assertErr(result, { type: "NotFound" });

A Result is either Ok (success with a value) or Err (failure with an error). Create them with ok and err.

import { assertEqual } from "@evolu/common";

type Result<T, E = never> = Ok<T> | Err<E>;

interface Ok<T> {
  readonly ok: true;
  readonly value: T;
}

interface Err<E> {
  readonly ok: false;
  readonly error: E;
}

const result: Result<number> = { ok: true, value: 1 };
assertEqual(result, { ok: true, value: 1 });

Use trySync and tryAsync to intentionally convert thrown values and Promise rejections into typed, recoverable errors represented by Result.

Do not wrap every API that throws or rejects in Result. If an error is unrecoverable and the caller has no meaningful fallback, let it propagate.

Since Result is a plain object, imperative code works naturally.

import {
  assertOk,
  assertType,
  ok,
  type Result,
  type Typed,
} from "@evolu/common";

interface User {
  readonly id: string;
}

const getUser = (): Result<User, UserNotFoundError> => ok({ id: "user-1" });

interface UserNotFoundError extends Typed<"UserNotFound"> {}

interface Profile {
  readonly userId: string;
}

const getProfile = (userId: string): Result<Profile, ProfileNotFoundError> =>
  ok({ userId });

interface ProfileNotFoundError extends Typed<"ProfileNotFound"> {}

const getCurrentProfile = (): Result<
  Profile,
  UserNotFoundError | ProfileNotFoundError
> => {
  const user = getUser();
  if (!user.ok) return user;

  return getProfile(user.value.id);
};

const profile = getCurrentProfile();
assertType<
  Result<Profile, UserNotFoundError | ProfileNotFoundError>,
  typeof profile
>();
assertOk(profile, { userId: "user-1" });

Note user and profile are named after their success values, not after the Result (userResult, profileResult).

If a Result has no success value, name it result. For several such operations, use allResult with { collect: false }.

Unrecoverable errors

Unrecoverable errors are failures the application can't handle meaningfully. For example, a database failure is unrecoverable when the application has no useful fallback, retry, or degraded mode.

Do not turn such failures into Result merely because the underlying API throws or rejects. Let the exception or Promise rejection propagate to the top-level handler so the application stops instead of continuing in a potentially invalid state.

In Evolu apps, the root Run reports defects and the platform lifecycle API owns application shutdown. For example, @evolu/nodejs provides runMain.

FAQ

Is Result expensive?

Wrapping a value in Result creates one small plain object and is very cheap. Its cost is usually worth considering only when a performance-critical loop creates millions of Results. Use Result by default; if profiling identifies such a loop as a bottleneck, benchmark the complete workload before optimizing it.

Why not generators?

Generator-based APIs make sequential workflows more concise: yield* combines error propagation (roughly replacing if (!result.ok) return result) with unwrapping the success value. That doesn't come for free. Supporting direct yield* requires adding iterator behavior to every Result; otherwise every use needs an adapter. That makes Result more than plain structural data: after serialization, its iterator behavior must be restored before direct yield* works. Generator machinery also makes control flow, debugger stepping, and stack traces less direct while adding runtime overhead. Evolu instead keeps Result as plain data and makes error propagation explicit. With AI, explicit checks are cheap to write.

Generators do not make accidental omission impossible: a function returning a lazy operation can be called without composing the returned value with yield*, leaving the operation out of the workflow, just as a Result-returning function can be called and its Result ignored. Dedicated tooling can detect these omissions, but that safety comes from the tooling, not generator syntax itself.

The intended way to write Evolu code is with test-driven development (TDD). Tests document the intended behavior and serve as its runnable specification, including failure paths. AI makes writing and maintaining those tests cheap. exhaustiveCheck complements tests by having TypeScript report newly added error variants.

What if a function doesn't return a value on success?

Use Result<void, E> and return ok() (no argument). Don't return ok(true), ok("success"), or ok("done")ok() already signals success; redundant values add noise.

Core

NameDescription
ErrAn error Result.
OkA successful Result.
AnyResultShorthand for a Result with any type parameters.
InferErrInfers the error type from a Result.
InferOkInfers the success value type from a Result.
ResultA discriminated success or failure value: either Ok or Err.
errCreates an Err result.
okCreates an Ok result.

Guards

FunctionDescription
isErrType guard for Err results.
isOkType guard for Ok results.

Unwrapping

FunctionDescription
getOkGets the value from a Result whose error type is never.
getOrNullGets the value from an Ok, or returns null for an Err.
getOrThrowGets the value from an Ok, or throws for an Err.

Composition

FunctionDescription
allResultCollects successful values from Results.
anyResultReturns the first successful Result.
flatMapResultComposes a successful Result with another Result-returning operation.

Exception interop

FunctionDescription
tryAsyncWraps an async function that may throw or reject, returning a Result.
trySyncWraps a synchronous function that may throw, returning a Result.

Pull

NameDescription
DoneA signal indicating normal completion of a pull-based protocol.
ExcludeDoneRemoves Done from an error union.
InferDoneInfers the done value type from a NextResult.
NextResultA result for a pull-based protocol with three outcomes.
OnlyDoneExtracts only Done from an error union.
doneConstructs a Done value.