In TypeScript, error handling is commonly done via try/catch blocks. This approach works, but has a few shortcomings:
try/catch.The Result<T> class from @tusss/ood provides an alternative: a typed return value that explicitly represents either a success (carrying data) or a failure (carrying an ErrorBase error). The caller is forced to check result.ok before accessing data, making error handling an explicit, first-class concern.
import { Result, ErrorBase } from "@tusss/ood";
interface User {
id: string;
email: string;
}
// Errors specific to this domain
class UserNotFoundError extends ErrorBase {
override readonly name = "UserNotFoundError";
constructor(id: string) {
super("USER_NOT_FOUND", `User with ID "${id}" was not found`);
}
}
class InvalidEmailError extends ErrorBase {
override readonly name = "InvalidEmailError";
constructor(email: string) {
super("INVALID_EMAIL", `"${email}" is not a valid email address`);
}
}
// In-memory mock store
const users: User[] = [
{ id: "u1", email: "alice@example.com" },
];
// Return Result<User> instead of throwing
async function findUserById(id: string): Promise<Result<User>> {
const user = users.find((u) => u.id === id);
if (!user) {
return new Result({ error: new UserNotFoundError(id) });
}
return new Result({ data: user });
}
async function updateUserEmail(id: string, email: string): Promise<Result<User>> {
if (!email.includes("@")) {
return new Result({ error: new InvalidEmailError(email) });
}
const findResult = await findUserById(id);
if (!findResult.ok) {
// Propagate the inner error upwards
return new Result({ error: findResult.error });
}
findResult.data.email = email;
return new Result({ data: findResult.data });
}
At the call site, you check result.ok before accessing result.data, treating both success and failure as first-class code paths:
async function main() {
// --- Scenario 1: Successful lookup ---
const found = await findUserById("u1");
if (found.ok) {
console.log("Found user:", found.data.email);
// Found user: alice@example.com
} else {
console.error("Error:", found.error?.message);
}
// --- Scenario 2: Not found ---
const missing = await findUserById("u99");
if (!missing.ok) {
console.error(`[${missing.error?.code}] ${missing.error?.message}`);
// [USER_NOT_FOUND] User with ID "u99" was not found
}
// --- Scenario 3: Invalid input ---
const badEmail = await updateUserEmail("u1", "not-an-email");
if (!badEmail.ok) {
console.error(`[${badEmail.error?.code}] ${badEmail.error?.message}`);
// [INVALID_EMAIL] "not-an-email" is not a valid email address
}
}
main();
Result composes neatly with HTTP response mapping. You can write a single handler that delegates all error logic to the service layer:
import { Request, Response } from "express";
import { findUserById, updateUserEmail } from "./user.service";
export async function getUserHandler(req: Request, res: Response) {
const result = await findUserById(req.params.id);
if (!result.ok) {
return res.status(mapErrorToStatus(result.error!.code)).json({
success: false,
error: result.error!.toStruct(),
});
}
return res.status(200).json({
success: true,
data: result.data,
});
}
function mapErrorToStatus(code: string): number {
switch (code) {
case "USER_NOT_FOUND": return 404;
case "INVALID_EMAIL": return 400;
default: return 500;
}
}
Since Result extends Model, calling result.toStruct() returns a plain-object representation of { ok, data, error }, which is safe to serialize directly into JSON API responses or pass across Next.js server-client component boundaries.