
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 porswitch (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 dothis is Left<L, R>do retorno do método, sem precisar deswitchnem 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/effect—Eithercommap,chain,fold,pipe, permitindo compor transformações sem sair do "container" (pipe(either, map(f), chain(g))).neverthrow—Result<T, E>com API fluente (.map(),.andThen(),.unwrapOr()), popular em projetos TypeScript que não querem toda a carga conceitual dofp-ts.- Rust
Result<T, E>— o mesmo conceito na linguagem, com o operador?para propagar erro automaticamente semifexplí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
| Aspecto | Either<L, R> | throw | { ok, error } |
|---|---|---|---|
| Erros documentados no tipo | ✅ Union type explícita | ❌ Implícito | ⚠️ Só se usar discriminated union |
| Type narrowing | ✅ isLeft() / 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ção | Baixo (~40 linhas, sem dependência) | Nenhum | Baixo |
| Integra com NestJS/HTTP | Precisa 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.tscomLeft,Right,Either,left(),right()(narrowing por métodoisLeft()/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) viraleft(error)preservando o tipo; erro desconhecido viralogger.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.valuee só então acessaresult.valuedo lado de sucesso - Repositories fake de teste expõem uma flag (
forceErrorou similar) para conseguir exercitar o branch de erro inesperado - Testes cobrem
isLeft()eisRight(), sempre checando o tipo de.valuedentro doif
🔑 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 EitherINMETA - src/core/errors/— hierarquia de erros (AppError,ConflictError, etc.) usada como tipoLINMETA - src/application/use-cases/employees/create-employee.ts— use-case com catch conhecido + desconhecidoINMETA - src/application/use-cases/employees/delete-employee.ts—Either<Error, void>INMETA - src/infra/database/repositories/prisma-employee.repository.ts— onde oConflictErroré lançadoINMETA - src/infra/http/controllers/employee/create-employee.controller.ts— consumidor típico do EitherINMETA - __tests__/unit/use-cases/employees/create-employee.spec.ts— testes dos dois ladosINMETA - __tests__/test-repositories/in-memory-employee-repository.ts— flagforceErrorpara testar o branch de erro inesperado