Skills

Install

$ npx ai-agents-skills add --skill result-pattern
Domain v1.0

Result Pattern

Wraps operation outcomes in Result<T> representing success or failure. Alternative to throwing exceptions for expected errors—provides explicit error paths and forces consumers to handle errors.

When to Use

  • Expected business errors (validation failed, user not found, unauthorized)
  • Chaining multiple operations that can fail
  • Type-safe error handling across layers
  • API endpoints that need different HTTP status codes per error type

Don’t use for:

  • Truly unexpected errors (null pointer, out of memory) → throw exceptions
  • Simple getters that can’t fail
  • Internal private helpers

Critical Patterns

✅ REQUIRED: Basic Result

export class Result<T> {
  private constructor(
    public readonly isSuccess: boolean,
    public readonly value?: T,
    public readonly error?: string
  ) {}

  static ok<T>(value: T): Result<T>       { return new Result(true, value); }
  static fail<T>(error: string): Result<T> { return new Result(false, undefined, error); }

  // Chain operations: if this failed, propagates failure; if success, applies fn
  flatMap<U>(fn: (value: T) => Result<U>): Result<U> {
    return this.isSuccess ? fn(this.value!) : Result.fail<U>(this.error!);
  }
}

// Usage
function divide(a: number, b: number): Result<number> {
  if (b === 0) return Result.fail("Cannot divide by zero");
  return Result.ok(a / b);
}

const result = divide(10, 2);
if (result.isSuccess) console.log(result.value); // 5
else console.error(result.error);

✅ REQUIRED: Chain with flatMap

function parseAge(s: string): Result<number> {
  const n = parseInt(s);
  return isNaN(n) ? Result.fail("Not a number") : Result.ok(n);
}
function validateAge(n: number): Result<number> {
  return n >= 0 && n < 150 ? Result.ok(n) : Result.fail("Age out of range");
}

// Chain: each step only runs if the previous succeeded
const result = parseAge("25").flatMap(validateAge);
if (result.isSuccess) console.log(result.value); // 25

✅ REQUIRED: Service Layer Returns Result

class UserService {
  async createUser(data: CreateUserDTO): Promise<Result<User>> {
    if (!data.email.includes("@")) return Result.fail("Invalid email");

    const existing = await this.repo.findByEmail(data.email);
    if (existing) return Result.fail("Email already registered");

    const user = await this.repo.create(data);
    return Result.ok(user);
  }
}

✅ REQUIRED: Controller Maps Result to HTTP

app.post("/users", async (req, res) => {
  const result = await userService.createUser(req.body);
  if (result.isSuccess) res.status(201).json(result.value);
  else res.status(400).json({ error: result.error });
});

✅ REQUIRED: Frontend — React Hook with Result

Use Result in hooks to surface typed errors without exceptions bubbling into components.

// Service — returns Result instead of throwing
async function submitOrder(items: OrderItem[]): Promise<Result<Order>> {
  if (items.length === 0) return Result.fail("EMPTY_ORDER");
  const res = await fetch("/api/orders", { method: "POST", body: JSON.stringify({ items }) });
  if (!res.ok) return Result.fail(res.status === 409 ? "ORDER_CONFLICT" : "SERVER_ERROR");
  return Result.ok(await res.json());
}

// Hook — drives UI state with typed error codes, no try/catch leaking into component
function useCreateOrder() {
  const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [errorCode, setErrorCode] = useState<string | null>(null);

  async function submit(items: OrderItem[]): Promise<void> {
    setState("loading");
    const result = await submitOrder(items);
    if (result.isSuccess) { setState("success"); }
    else                   { setState("error"); setErrorCode(result.error!); }
  }

  return { submit, state, errorCode };
}

// Component maps error codes to readable messages — no string parsing
const errorMessages: Record<string, string> = {
  EMPTY_ORDER:    "Add at least one item.",
  ORDER_CONFLICT: "This order was already placed.",
  SERVER_ERROR:   "Something went wrong. Try again.",
};

❌ NEVER: Swallow Errors Without Result

// ❌ WRONG: Silent failure, caller doesn't know what happened
try { await createUser(data); } catch { /* nothing */ }

// ✅ CORRECT: Explicit result
const result = await createUser(data);
if (!result.isSuccess) handleError(result.error);

Decision Tree

Expected business error (validation, not found, unauthorized)?
  → Return Result.fail("message")

Programmer error (null pointer, wrong arg type)?
  → Throw exception (not Result)

Multiple operations that can fail sequentially?
  → Chain with flatMap, or check isSuccess at each step

API endpoint needs to return different HTTP codes per error?
  → Service returns Result → controller maps Result to HTTP status

Need typed error variants (ValidationError, NotFoundError)?
  → Add a discriminated union error type to Result<T, E> — see references/advanced-patterns.md

Operation may or may not return a value (nullable)?
  → Return Result<T | undefined> or use a dedicated wrapper — see references/advanced-patterns.md

Example

End-to-end: service returns Result → controller maps each failure to the correct HTTP status code.

// Service layer — returns typed Result with business errors
class OrderService {
  async placeOrder(userId: string, dto: PlaceOrderDTO): Promise<Result<Order>> {
    const user = await this.userRepo.findById(userId);
    if (!user)               return Result.fail("USER_NOT_FOUND");
    if (!user.isActive)      return Result.fail("USER_INACTIVE");
    if (dto.items.length === 0) return Result.fail("EMPTY_ORDER");

    const order = Order.create(userId, dto.items);
    await this.orderRepo.save(order);
    return Result.ok(order);
  }
}

// Controller layer — maps each error code to an appropriate HTTP status
app.post("/api/v1/orders", async (req, res) => {
  const result = await orderService.placeOrder(req.user.id, req.body);

  if (result.isSuccess) {
    return res.status(201).json(result.value);
  }

  const statusMap: Record<string, number> = {
    USER_NOT_FOUND: 404,
    USER_INACTIVE:  403,
    EMPTY_ORDER:    400,
  };
  const status = statusMap[result.error!] ?? 500;
  return res.status(status).json({ error: result.error });
});

Patterns applied: service returns Result.ok / Result.fail, error codes are plain strings the controller maps to HTTP statuses, no try/catch needed — all paths are explicit.


Edge Cases

Team unfamiliarity: Result pattern has a learning curve. If team is unfamiliar, introduce gradually (one service at a time).

Async chains: flatMap with async functions requires await at each step or wrapping with Promise.all.

Third-party libraries that throw: Wrap in try/catch and convert to Result at the boundary.

Too granular: Don’t wrap every private helper in Result — only public API surfaces and operations that callers need to handle explicitly.


Conventions

ExceptionsResult Pattern
Error visibilityHidden (throws anywhere)Explicit (return type)
Handlingtry/catch (easy to forget)Type system forces it
Best forBugs, unexpected errorsBusiness errors

Use both: exceptions for programmer errors, Result for business errors.


Resources