Banner da postagem: Either<Error, Success>: padrão Right-Left nos use-cases TypeScript
TypeScript
Tutorial

Artigo

Either<Error, Success>: padrão Right-Left nos use-cases TypeScript

Aprenda como o padrão Either<Error, Success> melhora a tipagem e o fluxo de erros nos use-cases TypeScript, sem uso de exceções.


Como um use-case comunica "isso pode falhar de N jeitos diferentes" no próprio tipo de retorno, sem lançar exceção e sem o chamador precisar adivinhar. Este tutorial cobre o Either em si — a estrutura, por que ela é assim, e como este projeto a usa dentro dos use-cases. A tradução do erro para resposta HTTP (o AppErrorFilter) é um tópico à parte, coberto no tutorial exception-filter.md.


⚡ Resposta Rápida

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

// quem chama:
const result = await execute();
if (result.isLeft()) {
  // aqui o TS sabe que result.value é MyDomainError
  return;
}
// aqui, sem cast nenhum, o TS já sabe que result.value é { data: Something }

A regra em uma frase: o use-case nunca lança, sempre retorna left() ou right(); quem chama é obrigado pelo compilador a checar isLeft() antes de tocar no valor.


🔀 Onde a Conversão Acontece: Exceção → Either

O Either não elimina exceções do sistema — camadas mais baixas (Prisma, bibliotecas externas) continuam lançando. O que o padrão define é uma fronteira: exceções que atravessam essa fronteira viram left(), nunca continuam subindo como throw.

┌────────────────────────────────────────────────────────────────────┐
│  repository (infra)                                                │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ catch (error) {                                             │   │
│  │   if (é violação de unique constraint)                      │   │
│  │     throw new ConflictError("...")   ← erro tipado          │   │
│  │   throw error                        ← propaga o resto      │   │
│  │ }                                                           │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                          │ throw                                   │
│                          ▼                                         │
│  use-case (application) — A FRONTEIRA                              │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ try {                                                       │   │
│  │   ...validação, regra de negócio, chamada ao repository..   │   │
│  │ } catch (error) {                                           │   │
│  │   if (error instanceof ConflictError)                       │   │
│  │     return left(error)         ← conhecido: repassa o tipo  │   │
│  │   logger.error("...", error)                                │   │
│  │   return left(new InternalError("...")) ← desconhecido      │   │
│  │ }                                                           │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                          │ Either<L, R> — nunca mais um throw      │
│                          ▼                                         │
│         segue para o controller (ver tutorial                      │
│         "AppError + ExceptionFilter" para o resto do fluxo)        │
└────────────────────────────────────────────────────────────────────┘

A partir do momento em que o use-case retorna, não existe mais throw no caminho de erro até o controller decidir reintroduzir um (throw result.value) de propósito, como ponte para o ExceptionFilter do NestJS.


📖 Aprofundamento do Padrão

O problema que ele resolve

Funções que podem falhar costumam usar uma de três abordagens, e nenhuma documenta os erros possíveis no tipo de retorno:

// ❌ null/undefined — quem chama precisa adivinhar o motivo da falha
function findUser(id: string): User | null { ... }

// ❌ throw — o compilador não obriga ninguém a tratar, o fluxo é implícito
function findUser(id: string): User {
  if (!id) throw new Error("Invalid id");
  ...
}

// ❌ callback de erro — vaza detalhe de implementação, difícil de compor
function findUser(id: string, cb: (err: Error | null, user?: User) => void) {}

Quem chama findUser não sabe, olhando só a assinatura, se pode receber um NotFoundError, um ValidationError, ou ambos.

A anatomia do Either<L, R>

Either representa um de dois valores possíveis, por convenção:

  • Left<L, R> — o caso de erro (o "L" fica à esquerda)
  • Right<L, R> — o caso de sucesso (o "R" de "right" também soa como "certo")
type Response = Either<
  ConflictError | ValidationError | InternalError, // o que pode dar errado
  { employee: Employee } // o que volta quando dá certo
>;

Como o tipo de retorno é Either<..., ...> e não Employee diretamente, o compilador não deixa o chamador acessar result.value.employee sem antes provar que está no ramo certo.

Por que classes com isLeft()/isRight(), e não uma union com _tag

A implementação mais comum no ecossistema JS/TS (ex: fp-ts) modela Either como uma union discriminada por propriedade:

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

Esse projeto usa classes com métodos type guard (isLeft(): this is Left<L, R>) em vez de discriminar por propriedade. A diferença prática:

  • Com _tag, o narrowing normalmente passa por switch (result._tag) ou por comparação de string (result._tag === "Left").
  • Com método type guard, um if (result.isLeft()) comum já é suficiente — o TypeScript aplica o narrowing a partir do this is Left<L, R> do retorno do método, sem precisar de switch nem de string literal.

Isso combina melhor com o estilo do resto do projeto (guard clauses com if/early return), que já é o padrão usado em toda a camada de use-cases.

Onde este projeto para, e onde o ecossistema vai além

A implementação em src/core/either.ts é deliberadamente mínima: só isLeft(), isRight() e os construtores left()/right(). Não existe map, chain, fold ou qualquer combinador funcional.

Bibliotecas maduras do ecossistema vão além:

  • fp-ts / effectEither com map, chain, fold, pipe, permitindo compor transformações sem sair do "container" (pipe(either, map(f), chain(g))).
  • neverthrowResult<T, E> com API fluente (.map(), .andThen(), .unwrapOr()), popular em projetos TypeScript que não querem toda a carga conceitual do fp-ts.
  • Rust Result<T, E> — o mesmo conceito na linguagem, com o operador ? para propagar erro automaticamente sem if explícito.
  • Go (value, error) — a versão "crua" do mesmo problema: duas variáveis de retorno em vez de um tipo soma, sem type narrowing algum.

Por que este projeto não adota map/chain? Porque o estilo dominante nos use-cases é uma sequência de validações com early return (valida entrada → busca recurso → checa regra → busca outro recurso → executa) — e early return com if já resolve isso sem precisar de uma API de composição. Combinadores funcionais compensam quando você encadeia transformações em cima do valor de sucesso; aqui, cada passo já teria que ser awaitado de qualquer forma, então o ganho de chain() sobre um if seria pequeno. Veja o exemplo real disso mais abaixo, em "Exemplo do Projeto".


🆚 Either vs Throw vs Result Object

AspectoEither<L, R>throw{ ok, error }
Erros documentados no tipo✅ Union type explícita❌ Implícito⚠️ Só se usar discriminated union
Type narrowingisLeft() / isRight()❌ Não aplicável⚠️ Precisa de type guard manual
Compilador obriga a tratar✅ Tipo é Either, não R direto❌ Unchecked exceptions⚠️ Só se error não for opcional
Stack trace preservado⚠️ Perde (erro foi convertido em dado)✅ Nativo⚠️ Perde
Custo de adoçãoBaixo (~40 linhas, sem dependência)NenhumBaixo
Integra com NestJS/HTTPPrecisa de um passo extra no controller✅ Nativo (ExceptionFilter já existe)Precisa do mesmo passo extra

O Either não substitui stack traces para debugging. Quando uma exceção inesperada é convertida em left(new InternalError(...)), o stack trace original só sobrevive se alguém logar antes de descartar — por isso o try/catch do use-case sempre loga antes de retornar o left.


✅ Faça / ❌ Não faça

// ✅ FAÇA: tipar explicitamente a união de erros que o use-case pode retornar
type Response = Either<
  NotFoundError | ConflictError | InternalError,
  { employee: Employee }
>;

// ❌ NÃO FAÇA: Either<Error, T> genérico — você perde a rastreabilidade e
// quem chama não sabe quais erros concretos podem vir
type Response = Either<Error, { employee: Employee }>;
// ✅ FAÇA: estreitar com isLeft()/isRight() antes de acessar .value
const result = await useCase.execute(input);
if (result.isLeft()) throw result.value;
// aqui result já é Right<..., { employee }> — sem cast

// ❌ NÃO FAÇA: acessar .value sem narrowing — o TS recusa a compilar
const result = await useCase.execute(input);
console.log(result.value.employee);
// Property 'employee' does not exist on type
// 'ConflictError | ValidationError | InternalError | { employee: Employee }'
// ✅ FAÇA: usar left()/right() como factory — é o que todo controller e
// use-case deste projeto faz, sem exceção (18 controllers, zero `new Left`)
return left(new ValidationError("Invalid data"));
return right({ employee });

// ❌ NÃO FAÇA: instanciar Left/Right diretamente fora de either.ts
return new Left(new ValidationError("Invalid data")); // funciona, mas quebra
// a convenção do resto
// do código
// ✅ FAÇA: no catch, repassar erro conhecido preservando o tipo original
} catch (error) {
  if (error instanceof ConflictError) return left(error);
  this.logger.error("Error creating employee", error);
  return left(new InternalError("Error creating employee"));
}

// ❌ NÃO FAÇA: embrulhar todo erro do catch em InternalError genérico —
// isso destrói a distinção entre "conflito de negócio" (409) e "bug/infra
// caiu" (500) que o resto do sistema depende para responder o status certo
} catch (error) {
  return left(new InternalError("Error creating employee"));
}

🎯 Exemplo do Projeto

A implementação do Either

INMETA - src/core/either.ts — 42 linhas, zero dependências:

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: validação, catch conhecido e catch desconhecido lado a lado

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

De onde vem o ConflictError que o catch acima espera

O ConflictError não nasce no use-case — ele é lançado pelo repository ao traduzir um erro específico do Prisma. 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;
  }
}

O repository lança; o use-case captura e decide se aquele throw vira um left() com tipo preservado (ConflictError) ou um left(new InternalError()) genérico.

Either<Error, void>: sucesso sem valor de retorno

Nem todo caso de sucesso carrega dado. 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"));
  }
}

E o controller correspondente nem precisa ler result.value no caminho de sucesso — só existe para o throw do caminho de erro. 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;
  }
}

O consumidor típico: controller com uma linha de narrowing

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

Todos os 18 controllers HTTP deste projeto seguem exatamente essa forma — if (result.isLeft()) throw result.value seguido do acesso a result.value já estreitado para o lado de sucesso.

Testando os dois lados

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

⚠️ Armadilhas

"Como testo o branch de InternalError se o meu repository fake (in-memory) nunca lança nada?" Esse foi um problema real neste projeto: os repositories in-memory usados nos testes unitários simplesmente não falham, então o catch do use-case nunca era exercitado. A solução, registrada no commit test: add ConflictError and forceError to in-memory repos, foi dar a cada repository fake uma flag forceError:

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

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

O teste liga a flag (repo.forceError = true) só no caso que precisa cobrir o catch desconhecido, e deixa desligada nos demais.

"Perdi o stack trace do erro original" Quando o catch faz return left(new InternalError("...")), o stack trace da exceção original (ex: PrismaClientKnownRequestError) não vai junto — InternalError é um objeto novo. Por isso todo catch deste projeto loga antes de embrulhar: this.logger.error("...", error) primeiro, return left(new InternalError(...)) depois. O log preserva o stack trace para debugging; o Either devolve uma resposta controlada para quem chamou.


📝 Checklist para Replicar em Outro Projeto

  • Criar either.ts com Left, Right, Either, left(), right() (narrowing por método isLeft()/isRight(), não por propriedade _tag)
  • Cada use-case tipa sua própria união de erros — nunca Either<Error, T> genérico
  • Use-case sempre com try/catch: erro conhecido (instanceof) vira left(error) preservando o tipo; erro desconhecido vira logger.error() + left(new InternalError())
  • Repository/camada externa lança erros de domínio tipados nos casos previsíveis (ex: violação de constraint → ConflictError) e deixa o resto propagar sem tratar
  • Controller (ou outro consumidor de borda) faz if (result.isLeft()) throw result.value e só então acessa result.value do lado de sucesso
  • Repositories fake de teste expõem uma flag (forceError ou similar) para conseguir exercitar o branch de erro inesperado
  • Testes cobrem isLeft() e isRight(), sempre checando o tipo de .value dentro do if

🔑 Resumo

Use Either<L, R> quando o chamador precisa saber, pelo tipo, quais erros um método pode devolver — é o caso de use-cases de aplicação que atravessam camadas (validação, regra de negócio, persistência) e cujo resultado vai virar uma resposta HTTP. Para erros de programação genuinamente excepcionais (bug, infra fora do ar), a exceção nativa (throw) ainda é a ferramenta certa — o Either deste projeto não a substitui, apenas garante que ela nunca escape do use-case sem virar um left() primeiro.


📚 Referência

  • INMETA - src/core/either.ts — implementação do Either
  • INMETA - src/core/errors/ — hierarquia de erros (AppError, ConflictError, etc.) usada como tipo L
  • INMETA - src/application/use-cases/employees/create-employee.ts — use-case com catch conhecido + desconhecido
  • INMETA - src/application/use-cases/employees/delete-employee.tsEither<Error, void>
  • INMETA - src/infra/database/repositories/prisma-employee.repository.ts — onde o ConflictError é lançado
  • INMETA - src/infra/http/controllers/employee/create-employee.controller.ts — consumidor típico do Either
  • INMETA - __tests__/unit/use-cases/employees/create-employee.spec.ts — testes dos dois lados
  • INMETA - __tests__/test-repositories/in-memory-employee-repository.ts — flag forceError para testar o branch de erro inesperado