Banner da postagem: Either<Error, Success>: Robust Error Handling in TypeScript Use Cases
TypeScript
Tutorial

Artigo

Either<Error, Success>: Robust Error Handling in TypeScript Use Cases

Learn how the Either<Error, Success> pattern enforces strong type-safe error flows in TypeScript use cases, avoiding exceptions for predictable control.


How does a use case clearly communicate "this can fail in several specific ways" via the return type itself, without throwing exceptions or leaving callers guessing? This guide walks through the Either pattern—its structure, rationale, and how it's used within this project. The translation of errors into HTTP responses (the AppErrorFilter) is handled in a separate tutorial: exception-filter.md.


⚡ Quick Overview

import { left, right, type Either } from "@/core/either";

type Response = Either<MyDomainError, { data: Something }>;

async function execute(): Promise<Response> {
  if (invalido) return left(new MyDomainError("..."));
  return right({ data });
}

// caller:
const result = await execute();
if (result.isLeft()) {
  // here TS knows result.value is MyDomainError
  return;
}
// here, with no cast, TS already knows result.value is { data: Something }

In a nutshell: your use case never throws. It always returns left() or right() and the TypeScript compiler forces callers to check isLeft() before accessing the value.


🔀 Where the Translation Happens: Exception → Either

Either doesn't eradicate all exceptions—lower layers (Prisma, third-party libraries) may still throw. This pattern defines a boundary: exceptions that cross it become left(), and are never re-thrown upward.

┌────────────────────────────────────────────────────────────────────┐
│  repository (infra)                                                │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ catch (error) {                                             │   │
│  │   if (is unique constraint violation)                      │   │
│  │     throw new ConflictError("...")   ← typed error        │   │
│  │   throw error                        ← propagate others    │   │
│  │ }                                                           │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                          │ throw                                   │
│                          ▼                                         │
│  use-case (application) — THE BOUNDARY                             │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ try {                                                       │   │
│  │   ...validation, business rule, call repository...          │   │
│  │ } catch (error) {                                           │   │
│  │   if (error instanceof ConflictError)                       │   │
│  │     return left(error)         ← known, pass through type   │   │
│  │   logger.error("...", error)                                │   │
│  │   return left(new InternalError("...")) ← unknown          │   │
│  │ }                                                           │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                          │ Either<L, R> — never throws again      │
│                          ▼                                         │
│         continues on to the controller (see tutorial              │
│         "AppError + ExceptionFilter" for remaining flow)         │
└────────────────────────────────────────────────────────────────────┘

From the moment a use case returns, there are no more thrown errors in the error flow, until the controller deliberately rethrows (throw result.value) to bridge into the NestJS ExceptionFilter.


📖 Deep Dive: The Pattern Explained

The Problem It Solves

Functions that may fail often use one of three common approaches, none of which explicitly document possible errors in the return type:

// ❌ null/undefined — caller must guess why it failed
function findUser(id: string): User | null { ... }

// ❌ throw — compiler doesn't enforce handling, flow is implicit
function findUser(id: string): User {
  if (!id) throw new Error("Invalid id");
  ...
}

// ❌ error callback — leaks implementation, hard to compose
function findUser(id: string, cb: (err: Error | null, user?: User) => void) {}

A caller can't know from the type signature if findUser will yield a NotFoundError, ValidationError, or both.

Anatomy of Either<L, R>

Either represents one of two possible values, usually:

  • Left<L, R> — the error case (the "L" or left)
  • Right<L, R> — the success case (the "R" for "right" as in "correct")
type Response = Either<
  ConflictError | ValidationError | InternalError, // what can go wrong
  { employee: Employee } // what comes back on success
>;

Because the return type is Either<..., ...>, not just Employee, the compiler won't let you touch result.value.employee without first proving you're on the correct branch.

Why Classes with isLeft()/isRight(), Not a Discriminated Union

Most JS/TS ecosystems (like fp-ts) model Either as a discriminated union:

type Either<L, R> = { _tag: "Left"; left: L } | { _tag: "Right"; right: R };

This project uses classes with type guard methods (isLeft(): this is Left<L, R>) instead. What does this mean?

  • With _tag, you typically narrow with switch (result._tag) or string comparison (result._tag === "Left").
  • With type guard methods, a simple if (result.isLeft()) does the trick—TypeScript applies narrowing from the method's return type, no switch or string literal needed.

It fits better with the project's overall style (guard clauses and early returns with if), already prevalent throughout use-case logic.

Where This Implementation Stops (and Community Libraries Go Further)

src/core/either.ts is intentionally minimal: just isLeft(), isRight(), and the factory functions left()/right(). There's no map, chain, fold, or other functional combinators.

Mature libraries in the ecosystem add much more:

  • fp-ts / effectEither with map, chain, fold, pipe, letting you compose transformations inside the container (pipe(either, map(f), chain(g))).
  • neverthrowResult<T, E> with a fluent API (.map(), .andThen(), .unwrapOr()), popular for TypeScript projects avoiding the complexity of fp-ts.
  • Rust's Result<T, E> — the same idea in the language, with the ? operator for implicit error propagation.
  • Go's (value, error) — the "raw" take: two return values instead of a sum type, with no type narrowing.

Why not provide map/chain here? Because these use-cases typically validate input, look up resources, check business rules, and "early return" errors along the way (ifs)—there's little gain from composition methods when you need to await each call anyway. See the real-world example below in "Project Example."


🆚 Either vs Throw vs Result Object

AspectEither<L, R>throw{ ok, error }
Docs errors in the type✅ Explicit union❌ Implicit⚠️ Only with discriminated union
Type narrowingisLeft() / isRight()❌ Not applicable⚠️ Needs manual type guard
Compiler enforces guards✅ Return is Either, never R direct❌ Unchecked exceptions⚠️ Only if error not optional
Stack trace preserved⚠️ Lost (error converted to data)✅ Native⚠️ Also lost
Adoption costLow (~40 lines, no deps)NoneLow
Integrates w/ NestJS/HTTPExtra controller step needed✅ Native (ExceptionFilter built-in)Same extra step as Either

Either does not replace stack traces for debugging. When an unexpected exception is turned into left(new InternalError(...)), the original stack is lost unless you log it—hence every catch in a use case logs the error before returning the left.


✅ Do / ❌ Don't

// ✅ DO: Explicitly type the error union possible from this use case
type Response = Either<
  NotFoundError | ConflictError | InternalError,
  { employee: Employee }
>;

// ❌ DON'T: Generic Either<Error, T> — you lose traceability, caller can't know what can happen
type Response = Either<Error, { employee: Employee }>;
// ✅ DO: Always narrow using isLeft()/isRight() before accessing .value
const result = await useCase.execute(input);
if (result.isLeft()) throw result.value;
// Here result is Right<..., { employee }> — no casting required

// ❌ DON'T: Access .value without narrowing — TS will reject it
const result = await useCase.execute(input);
console.log(result.value.employee);
// Property 'employee' does not exist on type
// 'ConflictError | ValidationError | InternalError | { employee: Employee }'
// ✅ DO: Use left()/right() as the constructor — all controllers
// and use-cases in this project use this style (18 controllers, zero new Left)
return left(new ValidationError("Invalid data"));
return right({ employee });

// ❌ DON'T: Instantiate Left/Right directly outside of either.ts
return new Left(new ValidationError("Invalid data")); // works, but breaks the code style
// ✅ DO: In catch, forward known errors, preserving their original type
} catch (error) {
  if (error instanceof ConflictError) return left(error);
  this.logger.error("Error creating employee", error);
  return left(new InternalError("Error creating employee"));
}

// ❌ DON'T: Wrap all caught errors in generic InternalError—
// this collapses the distinction between business conflict (409)
// and bug/infrastructure failure (500) that the rest of the stack relies on for status codes
} catch (error) {
  return left(new InternalError("Error creating employee"));
}

🎯 Project Example

The Either Implementation

INMETA - src/core/either.ts — 42 lines, no dependencies:

export class Left<L, R> {
  readonly value: L;

  constructor(value: L) {
    this.value = value;
  }

  isRight(): this is Right<L, R> {
    return false;
  }

  isLeft(): this is Left<L, R> {
    return true;
  }
}

export class Right<L, R> {
  readonly value: R;

  constructor(value: R) {
    this.value = value;
  }

  isRight(): this is Right<L, R> {
    return true;
  }

  isLeft(): this is Left<L, R> {
    return false;
  }
}

export type Either<L, R> = Left<L, R> | Right<L, R>;

export function left<L, R>(value: L): Either<L, R> {
  return new Left(value);
}

export function right<L, R>(value: R): Either<L, R> {
  return new Right(value);
}

Use Case: Validation plus Known and Unknown Catch Handlers

INMETA - src/application/use-cases/employees/create-employee.ts:

type CreateEmployeeUseCaseResponse = Either<
  ConflictError | ValidationError | InternalError,
  { employee: Employee }
>;

@Injectable()
export class CreateEmployeeUseCase {
  private readonly logger = new Logger(CreateEmployeeUseCase.name);

  constructor(private readonly employeeRepository: EmployeeRepository) {}

  async execute(
    input: CreateEmployeeDto,
  ): Promise<CreateEmployeeUseCaseResponse> {
    try {
      const parsed = CreateEmployeeSchema.safeParse(input);

      if (!parsed.success) {
        return left(new ValidationError("Invalid employee data"));
      }

      const { name, email } = parsed.data;
      const employee = Employee.create({ name, email });
      const response = await this.employeeRepository.create(employee);

      return right({ employee: response });
    } catch (error) {
      if (error instanceof ConflictError) {
        return left(error);
      }

      this.logger.error("Error creating employee", error);
      return left(new InternalError("Error creating employee"));
    }
  }
}

Where the ConflictError Comes From

ConflictError is not created in the use-case—it's thrown by the repository when translating a specific Prisma error. INMETA - src/infra/database/repositories/prisma-employee.repository.ts:

async create(employee: Employee): Promise<Employee> {
  try {
    const entity = await this.prisma.employee.create({
      data: PrismaEmployeeMapper.toPrisma(employee)
    });

    return PrismaEmployeeMapper.toDomain(entity);
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === UNIQUE_CONSTRAINT_ERROR_CODE
    ) {
      throw new ConflictError("Employee with this email already exists");
    }

    throw error;
  }
}

The repository throws; the use-case catches and decides if the error becomes a left() with the precise type (ConflictError) or a generic left(new InternalError()).

Either<Error, void>: Success with No Return Value

Not all successes yield data. INMETA - src/application/use-cases/employees/delete-employee.ts:

type DeleteEmployeeUseCaseResponse = Either<
  ValidationError | NotFoundError | InternalError,
  void
>;

async execute(employeeId: string): Promise<DeleteEmployeeUseCaseResponse> {
  try {
    if (!employeeId) {
      return left(new ValidationError("Employee ID is required"));
    }

    const existing = await this.employeeRepository.findById(employeeId);

    if (!existing) {
      return left(new NotFoundError("Employee", employeeId));
    }

    await this.employeeRepository.delete(employeeId);

    return right(undefined);
  } catch (error) {
    this.logger.error("Failed to delete employee", error);
    return left(new InternalError("Failed to delete employee"));
  }
}

Controllers in such cases don't need to read result.value after success—the value serves only for errors. INMETA - src/infra/http/controllers/employee/delete-employee.controller.ts:

async handle(@Param("id") id: string) {
  const result = await this.deleteEmployee.execute(id);

  if (result.isLeft()) {
    throw result.value;
  }
}

Typical Consumer: Slim Controller

INMETA - src/infra/http/controllers/employee/create-employee.controller.ts:

async handle(
  @Body(new ZodValidationPipe(CreateEmployeeSchema)) input: CreateEmployeeDto
) {
  const result = await this.createEmployee.execute(input);

  if (result.isLeft()) {
    throw result.value;
  }

  return ApiResponse.ok(EmployeePresenter.toHTTP(result.value.employee));
}

All 18 HTTP controllers in this project share this exact flow — if (result.isLeft()) throw result.value, then access result.value as the already-narrowed success type.

Testing Both Sides

INMETA - __tests__/unit/use-cases/employees/create-employee.spec.ts:

it("creates employee and returns it on success", async () => {
  const { sut, repo } = makeSut();
  const input = { name: "John Doe", email: "john@example.com" };

  const result = await sut.execute(input);

  expect(result.isRight()).toBe(true);
  if (result.isRight()) {
    expect(result.value.employee.name).toBe("John Doe");
  }
});

it("returns InternalError when repository throws", async () => {
  const { sut, repo } = makeSut();
  const input = { name: "John Doe", email: "john@example.com" };
  repo.forceError = true;

  const result = await sut.execute(input);

  expect(result.isLeft()).toBe(true);
  if (result.isLeft()) {
    expect(result.value).toBeInstanceOf(InternalError);
  }
});

⚠️ Pitfalls

"How do I test the InternalError branch if my fake (in-memory) repository never throws?" This was a real issue: in-memory repositories used in unit tests simply don't fail, leaving the use-case catch branches untested. The fix, as per commit test: add ConflictError and forceError to in-memory repos, adds a forceError flag to test repos:

// __tests__/test-repositories/in-memory-employee-repository.ts
forceError = false;

async create(employee: Employee): Promise<Employee> {
  if (this.forceError) throw new Error("Forced error");
  ...
}

Set the flag (repo.forceError = true) only in tests that need to cover unknown errors—the rest stay unaffected.

"Original stack trace lost" When your catch block does return left(new InternalError("...")), the original stack (e.g. from PrismaClientKnownRequestError) is not carried over—InternalError is a fresh object. That's why every project catch logs the error first: this.logger.error("...", error) before returning the left. The log preserves the stack for debugging; the Either delivers a safe error response back to the caller.


📝 Checklist to Apply This Pattern Elsewhere

  • Create either.ts with Left, Right, Either, left(), and right() (narrow using method isLeft()/isRight(), not property _tag)
  • Each use-case defines its specific union of error types—never a generic Either<Error, T>
  • Use-cases always wrap in try/catch: known (using instanceof) errors return left(error) (original type); unknowns get logged and return left(new InternalError())
  • Repository/external layers throw domain-typed errors for foreseeable cases (e.g. constraint violation → ConflictError), otherwise rethrow
  • Controller (or other boundary consumer) always performs if (result.isLeft()) throw result.value, and only then safely uses the success-side result.value
  • Test fakes expose a forceError (or similar) flag to exercise unknown error branches
  • Tests always check isLeft() and isRight(), with type checking for .value within if

🔑 Summary

Use Either<L, R> when it matters for callers to know up front which errors a method can yield—perfect for application use-cases that cross boundaries (validation, business logic, persistence) whose outcome will power an HTTP response. For truly unexpected, programming or system failures, classic thrown exceptions still have their place—this Either only ensures those never escape a use-case without first being wrapped in a left().


📚 Further Reference

  • INMETA - src/core/either.ts — Either implementation
  • INMETA - src/core/errors/ — error hierarchy (AppError, ConflictError, etc.) used as type L
  • INMETA - src/application/use-cases/employees/create-employee.ts — use case with both known and unknown error catches
  • INMETA - src/application/use-cases/employees/delete-employee.tsEither<Error, void>
  • INMETA - src/infra/database/repositories/prisma-employee.repository.ts — where ConflictError is thrown
  • INMETA - src/infra/http/controllers/employee/create-employee.controller.ts — typical Either consumer
  • INMETA - __tests__/unit/use-cases/employees/create-employee.spec.ts — testing both sides
  • INMETA - __tests__/test-repositories/in-memory-employee-repository.tsforceError flag for testing unexpected error branches