API reference / @evolu/common / Result / Result

Type Alias: Result<T, E>

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

Defined in: packages/common/src/Result.ts:345

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 tell us.

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 errors safely. TypeScript can have this too via the Result type.

The Result type can be either Ok (success) or Err (error). Use ok to create a successful result and err to create an error result.

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 safe code, use ok and err.
  • For unsafe code, use trySync or tryAsync.

Safe asynchronous code (using Result with a Promise):

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

For lazy, cancellable async operations, see Task.

Naming convention

  • For values you need: use a name without Result suffix (user, config)
  • For void operations: use result (no value to name)

For multiple void operations, use block scopes to avoid potentially long names like createBaseTablesResult, createRelayTablesResult, or counters like result1, result2:

const processUser = () => {
  const user = getUser();
  if (!user.ok) return user;

  const result = saveToDatabase(user.value);
  if (!result.ok) return result;

  return ok();
};

const setupDatabase = () => {
  // Multiple void operations - use block scopes to avoid name clash
  {
    const result = createBaseTables();
    if (!result.ok) return result;
  }
  {
    const result = createRelayTables();
    if (!result.ok) return result;
  }

  return ok();
};

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 result = deps.sqlite.transaction(() => {
  const result = dropAllTables(deps);
  if (!result.ok) return result;

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

    {
      const result = ensureDbSchema(deps)(
        message.restore.dbSchema,
        dbSchema.value,
      );
      if (!result.ok) return result;
    }
    {
      const result = initializeDb(deps)(message.restore.mnemonic);
      if (!result.ok) return result;
    }
  }
  return ok();
});

if (!result.ok) {
  deps.postMessage({ type: "onError", error: result.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 = getBar(foo.value);
  if (!bar.ok) return bar;

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

Handling unexpected errors

Even with disciplined use of trySync and tryAsync, unexpected errors can still occur due to programming mistakes, third-party library bugs, or edge cases. These should be logged for debugging, but unexpected errors are not recoverable - they represent bugs that must be fixed.

Important: "Graceful shutdown" and error recovery can only come from expected errors handled via the Result type. Unexpected errors should fail fast - the operation fails immediately and the error bubbles up.

In browser environments

// Global error handler for unexpected errors
window.addEventListener("error", (event) => {
  console.error("Uncaught error:", event.error);
  // Send to error reporting service
  errorReportingService.report(event.error);
});

// For unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  errorReportingService.report(event.reason);
});

In Node.js environments

// Handle uncaught exceptions - log and fail fast
process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  errorReportingService.report(error);
  // Exit immediately - unexpected errors are not recoverable
  process.exit(1);
});

// Handle unhandled promise rejections
process.on("unhandledRejection", (reason) => {
  console.error("Unhandled promise rejection:", reason);
  errorReportingService.report(reason);
});

These global handlers serve as a safety net to log and report unexpected errors for debugging purposes. They do not attempt recovery - unexpected errors represent bugs that must be fixed. The discipline of explicit error handling through the Result pattern remains the primary approach for all recoverable scenarios.

FAQ

When should a function return a plain value instead of Result<T, E>?

Use Result<T, E> only when a function can fail with known, expected errors that callers need to handle. If a function cannot fail with a known error, return the value directly.

  • ✅ Return Result<User, UserNotFoundError> - can fail with a known error
  • ✅ Return User - cannot fail with a known error
  • ❌ Don't return Result<User, never> - unnecessary wrapper

This keeps the codebase clean and makes error handling intentional. The type system communicates which operations can fail and which cannot.

Unsafe code from external libraries (not under our control) should be wrapped with trySync or tryAsync at the boundaries. Once wrapped, if the error is not important to callers, functions can safely return plain values. If the error matters, use Result with a typed error.

// ✅ Safe to return void - unsafe code is wrapped and error is handled
const processData = (data: string): void => {
  const result = trySync(
    () => JSON.parse(data),
    (error) => ({ type: "ParseError", message: String(error) }),
  );

  if (!result.ok) {
    logError(result.error);
    return;
  }

  // Continue with safe operations...
};

// ✅ Can call without try-catch since it returns void
processData(jsonString);

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_config;`,
  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. 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.

Type Parameters

Type Parameter
T
E

Was this page helpful?