Kaique Mitsuo Silva Yamamoto
Arquitetura softwareFrontend webTypeScriptClean Code

Error Handling em TypeScript

Tratar erros como cidadãos de primeira classe — Result types, custom errors, narrowing de exceções em frontend e backend.

Error Handling em TypeScript

Em JavaScript, erros são invisíveis até o momento em que o código quebra em produção. TypeScript não muda esse comportamento em runtime — mas ele te dá ferramentas para tornar erros explícitos no sistema de tipos.

Os dois mundos do error handling

try/catch (exceções)Result types (valores)
Como funcionaErro "sobe" a call stack até ser capturadoErro é um valor retornado, como qualquer outro
VisibilidadeInvisível na assinatura da funçãoVisível no tipo de retorno
Quando usarErros inesperados (rede caiu, disco cheio)Erros esperados (validação, não encontrado)
ProblemaQuem chama pode esquecer de tratarForçado a tratar — TS não compila sem checagem

1. Result Types — O padrão mais limpo

Conceito

Um Result type é um discriminated union que representa sucesso ou falha como valores, não exceções.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

A função que retorna Result força quem chama a tratar ambos os casos.

Frontend — Validação de formulário

// contracts/validation.ts
type ValidationError = { field: string; message: string };
type FormResult<T> = Result<T, ValidationError[]>;

// validators/checkout.validator.ts
function validateCheckout(data: unknown): FormResult<CheckoutData> {
  const errors: ValidationError[] = [];

  if (!data || typeof data !== 'object') {
    return { ok: false, error: [{ field: 'form', message: 'Dados inválidos' }] };
  }

  if (!('email' in data) || typeof data.email !== 'string' || !data.email.includes('@')) {
    errors.push({ field: 'email', message: 'Email inválido' });
  }

  if (!('cardNumber' in data) || typeof data.cardNumber !== 'string' || data.cardNumber.length !== 16) {
    errors.push({ field: 'cardNumber', message: 'Número do cartão deve ter 16 dígitos' });
  }

  if (errors.length > 0) {
    return { ok: false, error: errors };
  }

  return { ok: true, value: data as CheckoutData };
}

// Componente — TS FORÇA você a tratar erro e sucesso
function CheckoutForm() {
  const handleSubmit = (formData: FormData) => {
    const result = validateCheckout(Object.fromEntries(formData));

    if (!result.ok) {
      // ✅ TS sabe que result.error é ValidationError[]
      result.error.forEach(err => {
        showFieldError(err.field, err.message);
      });
      return;
    }

    // ✅ TS sabe que result.value é CheckoutData
    submitOrder(result.value);
  };
}

Backend — Operações de banco

// repositories/user.repository.ts
type RepositoryError =
  | { kind: 'NOT_FOUND'; id: string }
  | { kind: 'DUPLICATE'; field: string; value: string }
  | { kind: 'CONNECTION_ERROR'; message: string };

class UserRepository {
  async findById(id: string): Promise<Result<User, RepositoryError>> {
    try {
      const user = await this.db.users.findOne({ _id: id });
      if (!user) {
        return { ok: false, error: { kind: 'NOT_FOUND', id } };
      }
      return { ok: true, value: user };
    } catch (err) {
      return { ok: false, error: { kind: 'CONNECTION_ERROR', message: String(err) } };
    }
  }

  async create(data: CreateUserDto): Promise<Result<User, RepositoryError>> {
    try {
      const existing = await this.db.users.findOne({ email: data.email });
      if (existing) {
        return { ok: false, error: { kind: 'DUPLICATE', field: 'email', value: data.email } };
      }
      const user = await this.db.users.create(data);
      return { ok: true, value: user };
    } catch (err) {
      return { ok: false, error: { kind: 'CONNECTION_ERROR', message: String(err) } };
    }
  }
}

// Service — TS FORÇA tratar cada tipo de erro
class UserService {
  async register(data: CreateUserDto) {
    const result = await this.userRepo.create(data);

    if (!result.ok) {
      // ✅ TS sabe que error é RepositoryError
      switch (result.error.kind) {
        case 'DUPLICATE':
          return { status: 409, body: { error: `${result.error.field} já cadastrado` } };
        case 'CONNECTION_ERROR':
          return { status: 503, body: { error: 'Serviço indisponível' } };
      }
    }

    // ✅ TS sabe que result.value é User
    await this.emailService.sendWelcome(result.value.email);
    return { status: 201, body: result.value };
  }
}

2. Custom Error Classes — Para erros inesperados

Estrutura base

// errors/base.error.ts
abstract class AppError extends Error {
  abstract readonly statusCode: number;
  abstract readonly isOperational: boolean; // true = esperado, false = bug

  constructor(
    message: string,
    public readonly context?: Record<string, unknown>,
  ) {
    super(message);
    this.name = this.constructor.name;
  }
}

Frontend — Erros de API tipados

// errors/api.errors.ts
class ApiError extends AppError {
  readonly isOperational = true;

  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly errorCode: string,
    context?: Record<string, unknown>,
  ) {
    super(message, context);
  }
}

class NetworkError extends ApiError {
  constructor(message = 'Falha na conexão') {
    super(message, 0, 'NETWORK_ERROR');
  }
}

class UnauthorizedError extends ApiError {
  constructor(message = 'Sessão expirada') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

class ValidationError extends AppError {
  readonly statusCode = 400;
  readonly isOperational = true;

  constructor(
    public readonly fieldErrors: Record<string, string[]>,
  ) {
    super('Erro de validação', { fieldErrors });
  }
}

// Uso no frontend — handler global de erros
function handleApiError(error: unknown) {
  if (error instanceof NetworkError) {
    showToast('Sem conexão. Verifique sua internet.');
    return;
  }

  if (error instanceof UnauthorizedError) {
    redirectToLogin();
    return;
  }

  if (error instanceof ValidationError) {
    // ✅ TS sabe que fieldErrors existe
    Object.entries(error.fieldErrors).forEach(([field, messages]) => {
      showFieldError(field, messages.join(', '));
    });
    return;
  }

  // Erro desconhecido
  showToast('Algo deu errado. Tente novamente.');
  reportError(error); // envia para Sentry, Datadog, etc.
}

Backend — Middleware de erro no Express

// errors/order.errors.ts
class InsufficientStockError extends AppError {
  readonly statusCode = 409;
  readonly isOperational = true;

  constructor(
    public readonly productId: string,
    public readonly requested: number,
    public readonly available: number,
  ) {
    super(`Estoque insuficiente para ${productId}`, { productId, requested, available });
  }
}

class PaymentDeclinedError extends AppError {
  readonly statusCode = 402;
  readonly isOperational = true;

  constructor(
    public readonly reason: string,
    public readonly gatewayCode: string,
  ) {
    super(`Pagamento recusado: ${reason}`, { gatewayCode });
  }
}

// middleware/error-handler.ts — Express error middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof AppError) {
    // Erro operacional — esperado, log como warning
    logger.warn(err.message, { statusCode: err.statusCode, context: err.context });

    return res.status(err.statusCode).json({
      error: err.message,
      code: err.constructor.name,
      ...(err instanceof ValidationError && { fields: err.fieldErrors }),
      ...(err instanceof InsufficientStockError && {
        productId: err.productId,
        available: err.available,
      }),
    });
  }

  // Erro não operacional — bug, log como error
  logger.error('Unhandled error', { error: err.stack });
  return res.status(500).json({ error: 'Erro interno do servidor' });
}

3. Narrowing de erros no catch

O TypeScript tipa catch como unknown (com useUnknownInCatchVariables ou strict). Isso é uma feature, não um bug — te força a validar antes de acessar propriedades.

// ❌ Assumir que o erro é Error
try {
  await fetch('/api/data');
} catch (err) {
  console.log(err.message); // ❌ TS erro — err pode ser string, number, null, anything
}

// ✅ Validar antes de usar
try {
  await fetch('/api/data');
} catch (err) {
  if (err instanceof Error) {
    console.log(err.message); // ✅ TS sabe que é Error
  } else {
    console.log('Erro desconhecido:', err);
  }
}

// ✅✅ Com type guard reutilizável
function isError(value: unknown): value is Error {
  return value instanceof Error;
}

try {
  await riskyOperation();
} catch (err) {
  const message = isError(err) ? err.message : String(err);
  logger.error(message);
}

4. Funções utilitárias para Result

// utils/result.ts — helpers que simplificam o uso

// Extrai o valor ou lança o erro
function unwrap<T, E>(result: Result<T, E>): T {
  if (result.ok) return result.value;
  throw result.error;
}

// Mapeia o valor de sucesso
function mapResult<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  if (result.ok) return { ok: true, value: fn(result.value) };
  return result;
}

// Combina múltiplos Results
function combineResults<T, E>(results: Result<T, E>[]): Result<T[], E> {
  const values: T[] = [];
  for (const result of results) {
    if (!result.ok) return result;
    values.push(result.value);
  }
  return { ok: true, value: values };
}

// Uso:
const results = await Promise.all([
  validateEmail(data.email),
  validatePassword(data.password),
  validateCPF(data.cpf),
]);

const combined = combineResults(results);
if (!combined.ok) {
  return res.status(400).json({ error: combined.error });
}
// combined.value é [string, string, string]

Resumo — Quando usar cada abordagem

SituaçãoAbordagemExemplo
Validação de inputResult typevalidateEmail() retorna Result<string, Error>
Operação de banco esperadaResult typefindById() retorna Result<User | null, Error>
Erro de rede/conexãoCustom Error + try/catchNetworkError lançado em catch
Erro de regra de negócioCustom ErrorInsufficientStockError
Erro global não esperadoError middlewareExpress error handler
Erro em componente ReactResult type ou Error BoundaryApiState discriminated union

Referências

On this page