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
okanderr. - For unsafe code, use
trySyncortryAsync.
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>orResult<T, E>). - After each operation, check
if (!result.ok)and return theErrto short-circuit. - If all operations succeed, return
ok()(or another value if needed). - Outside the transaction, handle the final
Resultto 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 |