API Reference / @evolu/common / Result

Result

πŸ›‘οΈ Type-Safe Errors

Intro

The problem with throwing an exception in JavaScript is that the caught error is always of an unknown type. The unknown type is a problem because we can't be sure all errors have been handled because the TypeScript compiler can't help us.

Some other languages like Rust πŸ¦€ or Haskell πŸ“š use a type-safe approach to error handling, where errors are explicitly represented as part of the return type, such as Result or Either, allowing the developer to handle all errors safely. βœ…

✨ Evolu uses Result, and it looks like this:

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

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

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

const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });

Now let's look at how Result can be used for safe JSON parsing:

interface ParseJsonError {
  readonly type: "ParseJsonError";
  readonly message: string;
}

const parseJson = (value: string): Result<unknown, ParseJsonError> => {
  try {
    return ok(JSON.parse(value));
  } catch (error) {
    return err({ type: "ParseJsonError", message: String(error) });
  }
};

// Result<unknown, ParseJsonError>
const json = parseJson('{"key": "value"}');

// Fail fast to handle errors early.
if (!json.ok) return json; // Err<ParseJsonError>

// Now, we have access to the json.value.
expectTypeOf(json.value).toBeUnknown();

Note how we didn't have to use the try/catch, just if (!json.ok), and how the error isn't unknown but has a type.

But we had to use try/catch in the parseJson function. For such a case, wrapping unsafe code, Evolu provides the trySync helper:

const parseJson = (value: string): Result<unknown, ParseJsonError> =>
  trySync(
    () => JSON.parse(value) as unknown,
    (error) => ({ type: "ParseJsonError", message: String(error) }),
  );

✨ trySync helper makes unsafe (can throw) synchronous code safe; for unsafe asynchronous code, use tryAsync.

Let's summarize it:

  • For synchronous safe code, use ok and err.
  • For unsafe code, use trySync or tryAsync.
  • For asynchronous safe code, use Promise with Result.

Asynchronous safe (because of a Promise using Result) code is straightforward:

const fetchUser = async (
  userId: string,
): Promise<Result<User, FetchUserError>> => {
  // Simulate an API call
  return new Promise((resolve) => {
    setTimeout(() => {
      if (userId === "1") {
        resolve(ok({ id: "1", name: "Alice" }));
      } else {
        resolve(
          err({ type: "FetchUserError", reason: "user not found" }),
        );
      }
    }, 1000);
  });
};

Examples

Sequential Operations with Short-Circuiting

When performing a sequence of operations where any failure should stop further processing, use the Result type with early returns.

Here's an example of a database reset operation that drops tables, restores a schema, and initializes the database, stopping on the first error:

const reset = deps.sqlite.transaction(() => {
  const drop = dropAllTables(deps);
  if (!drop.ok) return drop;

  if (message.restore) {
    const dbSchema = getDbSchema(deps)();
    if (!dbSchema.ok) return dbSchema;

    const ensure = ensureDbSchema(deps)(
      message.restore.dbSchema,
      dbSchema.value,
    );
    if (!ensure.ok) return ensure;

    const init = initializeDb(deps)(message.restore.mnemonic);
    if (!init.ok) return init;
  }
  return ok();
});

if (!reset.ok) {
  deps.postMessage({ type: "onError", error: reset.error });
  return;
}

In this pattern:

  • Each operation returns a Result (e.g., Result<void, E> or Result<T, E>).
  • After each operation, check if (!result.ok) and return the Err to short-circuit.
  • If all operations succeed, return ok() (or another value if needed).
  • Outside the transaction, handle the final Result to report success or failure.

This approach ensures type-safe error handling, avoids nested try/catch blocks, and clearly communicates the control flow.

A function with two different errors:

const example = (value: string): Result<number, FooError | BarError> => {
  const foo = getFoo(value);
  if (!foo.ok) return foo;

  const bar = barize(foo.value);
  if (!bar.ok) return bar;

  return ok(barToNumber(bar.value));
};

FAQ

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

If your function performs an operation but doesn't need to return a value on success, you can use Result<void, E>. Using Result<void, E> is clearer than using Result<true, E> or Result<null, E> because it communicates that the function doesn't produce a value but can produce errors.

How do I short-circuit processing of an array on the first error?

If you want to stop processing as soon as an error occurs (short-circuit), you should produce and check each Result inside a loop:

for (const query of [
  sql`drop table evolu_owner;`,
  sql`drop table evolu_message;`,
]) {
  const result = deps.sqlite.exec(query);
  if (!result.ok) return result;
}
// All queries succeeded

How do I handle an array of operations and short-circuit on the first error?

If you have an array of operations (not results), you should make them lazyβ€”that is, represent each operation as a function (see LazyValue in Function.ts). This way, you only execute each operation as needed, and can stop on the first error:

import type { LazyValue } from "./Function";

const operations: LazyValue<Result<void, MyError>>[] = [
  () => doSomething(),
  () => doSomethingElse(),
];

for (const op of operations) {
  const result = op();
  if (!result.ok) return result;
}
// All operations succeeded

If you already have an array of Results, the processing has already happened, so you can't short-circuit. In that case, you can check for the first error:

const firstError = results.find((r) => !r.ok);
if (firstError) return firstError;
// All results are Ok

Why doesn't Evolu provide "handy helpers"?

Evolu intentionally favors imperative patterns (like the for...of loop above) over monadic helpers. Imperative code is generally more readable, easier to debug, and more familiar to most JavaScript and TypeScript developers. While monads and functional helpers can be powerful, they often obscure control flow and make debugging harder. Evolu's approach keeps error handling explicit and straightforward.

Interfaces

InterfaceDescription
ErrAn error Result.
OkA successful Result.

Type Aliases

Type AliasDescription
ResultA Result can be either Ok (success) or Err (error).

Functions

FunctionDescription
errCreates an Err result.
getOrThrowExtracts the value from a Result if it is an Ok, or throws an error if it is an Err.
okCreates an Ok result.
tryAsyncWraps async functions or any operation returning a promise, returning a Result.
trySyncWraps synchronous functions that may throw exceptions, returning a Result.

Was this page helpful?