Erros como valores: Result type em TypeScript

1. Por que tratar erros como valores?

O tratamento de erros em TypeScript tradicionalmente depende de try/catch e exceções. Essa abordagem apresenta limitações significativas:

  • Exceções não são tipadas: qualquer função pode lançar qualquer coisa, sem garantias de tipo
  • Propagação implícita: erros podem escapar sem serem declarados na assinatura da função
  • null/undefined como indicadores: forçam verificações manuais e podem causar erros em tempo de execução
// Problema: função não indica que pode falhar
function dividir(a: number, b: number): number {
  return a / b; // Pode retornar Infinity, não indica erro de divisão por zero
}

// Problema: null como indicador de erro
function buscarUsuario(id: number): Usuario | null {
  // ... implementação
}

Vantagens de um tipo explícito para sucesso e falha:
- Erros são declarados na assinatura da função
- O compilador força o tratamento de todos os casos
- Código mais previsível e auto-documentado

2. Implementando um Result type do zero

Vamos construir nosso próprio Result usando uma união discriminada:

type Result<T, E = Error> = Ok<T, E> | Err<T, E>;

class Ok<T, E> {
  readonly _tag = 'Ok';

  constructor(readonly value: T) {}

  isOk(): this is Ok<T, E> {
    return true;
  }

  isErr(): this is Err<T, E> {
    return false;
  }

  unwrap(): T {
    return this.value;
  }

  unwrapOr(defaultValue: T): T {
    return this.value;
  }
}

class Err<T, E> {
  readonly _tag = 'Err';

  constructor(readonly error: E) {}

  isOk(): this is Ok<T, E> {
    return false;
  }

  isErr(): this is Err<T, E> {
    return true;
  }

  unwrap(): never {
    throw new Error(`Tentou desencapsular um Err: ${this.error}`);
  }

  unwrapOr(defaultValue: T): T {
    return defaultValue;
  }
}

// Funções utilitárias
function ok<T, E = never>(value: T): Result<T, E> {
  return new Ok(value);
}

function err<T = never, E = Error>(error: E): Result<T, E> {
  return new Err(error);
}

function fromThrowable<T, E = Error>(
  fn: () => T,
  errorMapper?: (error: unknown) => E
): Result<T, E> {
  try {
    return ok(fn());
  } catch (error) {
    return err(errorMapper ? errorMapper(error) : error as E);
  }
}

3. Trabalhando com Result em cadeias de operações

Para tornar o Result verdadeiramente útil, adicionamos métodos de transformação:

// Métodos na classe Ok
class Ok<T, E> {
  // ... métodos anteriores

  map<U>(fn: (value: T) => U): Result<U, E> {
    return ok(fn(this.value));
  }

  mapErr<F>(_fn: (error: E) => F): Result<T, F> {
    return ok(this.value);
  }

  andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
    return fn(this.value);
  }

  orElse<F>(_fn: (error: E) => Result<T, F>): Result<T, E> {
    return ok(this.value);
  }
}

// Métodos na classe Err
class Err<T, E> {
  // ... métodos anteriores

  map<U>(_fn: (value: T) => U): Result<U, E> {
    return err(this.error);
  }

  mapErr<F>(fn: (error: E) => F): Result<T, F> {
    return err(fn(this.error));
  }

  andThen<U>(_fn: (value: T) => Result<U, E>): Result<U, E> {
    return err(this.error);
  }

  orElse<F>(fn: (error: E) => Result<T, F>): Result<T, F> {
    return fn(this.error as unknown as F);
  }
}

Exemplo prático de encadeamento:

function parseJSON(json: string): Result<unknown, string> {
  return fromThrowable(
    () => JSON.parse(json),
    () => 'JSON inválido'
  );
}

function validateUser(data: unknown): Result<{name: string; age: number}, string> {
  if (typeof data !== 'object' || data === null) {
    return err('Dados não são um objeto');
  }
  // ... validações adicionais
  return ok({ name: 'João', age: 30 });
}

function processUser(json: string): Result<string, string> {
  return parseJSON(json)
    .andThen(validateUser)
    .map(user => `Usuário: ${user.name}, ${user.age} anos`);
}

// Uso
const result = processUser('{"name":"João","age":30}');
if (result.isOk()) {
  console.log(result.unwrap()); // "Usuário: João, 30 anos"
} else {
  console.error(`Erro: ${result.unwrapOr('')}`);
}

4. Padrões de uso em aplicações reais

Validação de formulários

interface FormData {
  email: string;
  password: string;
}

function validateEmail(email: string): Result<string, string> {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email) 
    ? ok(email) 
    : err('Email inválido');
}

function validatePassword(password: string): Result<string, string> {
  return password.length >= 8 
    ? ok(password) 
    : err('Senha deve ter no mínimo 8 caracteres');
}

function validateForm(data: FormData): Result<FormData, string[]> {
  const errors: string[] = [];

  const emailResult = validateEmail(data.email);
  if (emailResult.isErr()) errors.push(emailResult.unwrapOr(''));

  const passwordResult = validatePassword(data.password);
  if (passwordResult.isErr()) errors.push(passwordResult.unwrapOr(''));

  return errors.length === 0 ? ok(data) : err(errors);
}

Chamadas a APIs

async function fetchUser(id: number): Promise<Result<User, NetworkError>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return err(new NetworkError(`HTTP ${response.status}`));
    }
    const data = await response.json();
    return ok(data as User);
  } catch (error) {
    return err(new NetworkError('Falha na conexão'));
  }
}

// Uso com async/await
async function getUserDisplay(id: number): Promise<Result<string, string>> {
  const result = await fetchUser(id);
  return result
    .map(user => `Nome: ${user.name}, Email: ${user.email}`)
    .mapErr(err => `Erro ao buscar usuário: ${err.message}`);
}

5. Integração com bibliotecas e ecossistema

Bibliotecas populares como neverthrow e oxide.ts oferecem implementações robustas:

// Exemplo com neverthrow
import { Result, ok, err } from 'neverthrow';

function parseConfig(config: string): Result<Config, string> {
  return fromThrowable(
    () => JSON.parse(config) as Config,
    () => 'Configuração inválida'
  );
}

// Adaptação de funções que lançam exceções
function safeJSONParse<T>(json: string): Result<T, string> {
  return fromThrowable(
    () => JSON.parse(json) as T,
    () => 'JSON inválido'
  );
}

// Interoperabilidade com Promises
async function safeFetch<T>(url: string): Promise<Result<T, string>> {
  try {
    const response = await fetch(url);
    if (!response.ok) return err(`HTTP ${response.status}`);
    const data = await response.json();
    return ok(data as T);
  } catch {
    return err('Erro de rede');
  }
}

6. Comparação com alternativas comuns

Result vs Either

Either é mais genérico (Left/Right), enquanto Result é semanticamente focado em sucesso/erro:

// Either (genérico)
type Either<L, R> = Left<L> | Right<R>;

// Result (semântico)
type Result<T, E> = Ok<T> | Err<E>;

Result vs Option/Maybe

Option representa ausência de valor, Result representa falha com informação:

type Option<T> = Some<T> | None;  // Apenas presença/ausência
type Result<T, E> = Ok<T> | Err<E>;  // Sucesso com valor ou erro com detalhes

Quando usar cada abordagem

  • Use Result: operações que podem falhar com múltiplos tipos de erro
  • Use exceções: erros críticos que não devem ser ignorados (ex: falta de memória)
  • Use Option: quando a ausência de valor é esperada (ex: busca em cache)

7. Boas práticas e armadilhas

Evitando unwrap() cegamente

// Ruim: pode lançar exceção em tempo de execução
const valor = resultado.unwrap();

// Bom: tratamento explícito
if (resultado.isOk()) {
  const valor = resultado.unwrap();
} else {
  const erro = resultado.unwrapOr(new Error('Valor padrão'));
}

Tipagem correta de Err

// Especificar tipos de erro melhora a documentação
type ApiError = { code: number; message: string };
type ValidationError = { field: string; message: string };

function saveUser(user: User): Result<boolean, ApiError | ValidationError> {
  // ...
}

Performance e legibilidade

  • Result adiciona overhead de alocação de objetos
  • Para operações críticas, considere usar exceções
  • Mantenha o encadeamento simples para evitar código difícil de ler
// Equilíbrio entre segurança e legibilidade
function processData(input: string): Result<number, string> {
  return safeJSONParse(input)
    .andThen(validateStructure)
    .map(transformData);
}

Referências