Padronização de respostas e erros no Express: contratos, status codes e error handling

Capítulo 8

Tempo estimado de leitura: 9 minutos

+ Exercício

Por que padronizar respostas e erros

Em uma API, a padronização de respostas cria um contrato previsível para quem consome (front-end, mobile, outros serviços). Isso reduz condicionais no cliente, facilita observabilidade, acelera integração e torna o tratamento de falhas consistente. O objetivo é: toda resposta ter um formato conhecido, com campos estáveis para sucesso e para erro, e status codes coerentes com o que aconteceu.

Dois princípios guiam esse capítulo:

  • Contrato estável: o cliente sempre sabe onde encontrar data, error, meta, etc.
  • Separação de responsabilidades: rotas e serviços lançam erros “sem se preocupar” com HTTP; um error handler central traduz para status code e payload.

Contrato de resposta: sucesso e erro

Formato sugerido

Um contrato simples e prático:

  • Sucesso: { ok: true, data, meta? }
  • Erro: { ok: false, error: { code, message, details? }, meta? }

Recomendações:

  • ok como booleano facilita o consumo no cliente.
  • code é um código interno (estável) para o cliente tomar decisões sem depender de texto.
  • message é uma mensagem amigável e segura (sem detalhes sensíveis).
  • details é opcional e deve ser usado com cuidado (ex.: erros de validação por campo).
  • meta pode carregar paginação, requestId, tempo de processamento, etc.

Tipos TypeScript para o contrato

export type ApiSuccess<T> = { ok: true; data: T; meta?: Record<string, unknown> };export type ApiError = { ok: false; error: { code: string; message: string; details?: unknown }; meta?: Record<string, unknown> };export type ApiResponse<T> = ApiSuccess<T> | ApiError;

Helpers para responder

Centralizar a criação do payload evita divergências entre endpoints.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

import { Response } from "express";import { ApiSuccess, ApiError } from "./api-response";export function sendOk<T>(res: Response, data: T, status = 200, meta?: Record<string, unknown>) {  const body: ApiSuccess<T> = { ok: true, data, ...(meta ? { meta } : {}) };  return res.status(status).json(body);}export function sendError(res: Response, status: number, code: string, message: string, details?: unknown, meta?: Record<string, unknown>) {  const body: ApiError = {    ok: false,    error: { code, message, ...(details !== undefined ? { details } : {}) },    ...(meta ? { meta } : {})  };  return res.status(status).json(body);}

Status codes: mapeamento prático

Status code comunica a categoria do resultado. O payload comunica o detalhe de negócio e o código interno.

CenárioStatusQuando usarExemplo de code interno
Sucesso (GET/PUT/PATCH)200Operação concluída com retorno-
Sucesso (POST criando)201Recurso criado-
Sem conteúdo204Operação ok sem body (opcional; se usar contrato, prefira 200 com body)-
Erro de validação400Payload inválido, regra de formatoVALIDATION_ERROR
Não autenticado401Token ausente/expirado/inválidoAUTH_UNAUTHENTICATED
Sem permissão403Autenticado, mas sem acessoAUTH_FORBIDDEN
Não encontrado404Recurso inexistenteRESOURCE_NOT_FOUND
Conflito409Violação de unicidade/estadoRESOURCE_CONFLICT
Erro de domínio (regra de negócio)422Dados válidos, mas regra impede operaçãoDOMAIN_RULE_VIOLATION
Rate limit429Muitas requisiçõesRATE_LIMITED
Erro inesperado500Falha não tratadaINTERNAL_ERROR

Observação: 422 é útil para regras de negócio (ex.: “saldo insuficiente”), enquanto 400 fica para problemas de formato/entrada.

Classes de erro: domínio, validação e autenticação

Em vez de retornar res.status(...) em todo lugar, lance erros tipados e deixe o handler central traduzir. Isso melhora testabilidade e mantém serviços independentes de HTTP.

Base: AppError com preservação de stack trace

Uma classe base deve:

  • Preservar stack trace (usando Error.captureStackTrace quando disponível).
  • Carregar status code e código interno.
  • Ter uma mensagem segura para o cliente.
  • Opcionalmente carregar details (ex.: campos inválidos) e cause (erro original).
export type AppErrorOptions = {  code: string;  status: number;  publicMessage: string;  details?: unknown;  cause?: unknown;};export class AppError extends Error {  public readonly code: string;  public readonly status: number;  public readonly publicMessage: string;  public readonly details?: unknown;  public readonly cause?: unknown;  constructor(message: string, opts: AppErrorOptions) {    super(message);    this.name = this.constructor.name;    this.code = opts.code;    this.status = opts.status;    this.publicMessage = opts.publicMessage;    this.details = opts.details;    this.cause = opts.cause;    if (Error.captureStackTrace) {      Error.captureStackTrace(this, this.constructor);    }  }}

Importante: message (do Error) pode conter detalhes técnicos para logs; publicMessage é o texto seguro para o cliente.

Erros específicos

export class ValidationError extends AppError {  constructor(publicMessage = "Dados inválidos", details?: unknown, cause?: unknown) {    super("Validation failed", {      code: "VALIDATION_ERROR",      status: 400,      publicMessage,      details,      cause    });  }}export class AuthError extends AppError {  constructor(publicMessage = "Não autenticado", cause?: unknown) {    super("Authentication required", {      code: "AUTH_UNAUTHENTICATED",      status: 401,      publicMessage,      cause    });  }}export class ForbiddenError extends AppError {  constructor(publicMessage = "Acesso negado", cause?: unknown) {    super("Forbidden", {      code: "AUTH_FORBIDDEN",      status: 403,      publicMessage,      cause    });  }}export class NotFoundError extends AppError {  constructor(publicMessage = "Recurso não encontrado", cause?: unknown) {    super("Resource not found", {      code: "RESOURCE_NOT_FOUND",      status: 404,      publicMessage,      cause    });  }}export class DomainError extends AppError {  constructor(code: string, publicMessage: string, details?: unknown, cause?: unknown) {    super("Domain rule violated", {      code,      status: 422,      publicMessage,      details,      cause    });  }}export class ConflictError extends AppError {  constructor(publicMessage = "Conflito de estado", details?: unknown, cause?: unknown) {    super("Conflict", {      code: "RESOURCE_CONFLICT",      status: 409,      publicMessage,      details,      cause    });  }}

Note que DomainError permite códigos internos mais específicos (ex.: INSUFFICIENT_BALANCE, ORDER_ALREADY_PAID), mantendo 422 como status padrão.

Error handler central no Express

Objetivos do handler

  • Garantir que toda exceção vire uma resposta no contrato.
  • Não vazar detalhes sensíveis (stack, SQL, tokens, paths internos).
  • Logar com contexto (requestId, rota, usuário) e preservar stack trace.
  • Mapear erros conhecidos para status codes; desconhecidos para 500.

Middleware de error handling

import { NextFunction, Request, Response } from "express";import { AppError } from "./errors";import { sendError } from "./response-helpers";function getRequestId(req: Request): string | undefined {  const header = req.header("x-request-id");  return header || undefined;}export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {  const requestId = getRequestId(req);  if (err instanceof AppError) {    // Log interno com detalhes técnicos e stack trace preservado    console.error({      requestId,      name: err.name,      code: err.code,      status: err.status,      message: err.message,      details: err.details,      stack: err.stack,      cause: err.cause    });    return sendError(      res,      err.status,      err.code,      err.publicMessage,      err.details,      requestId ? { requestId } : undefined    );  }  // Erro desconhecido: não expor detalhes ao cliente  console.error({ requestId, err });  return sendError(    res,    500,    "INTERNAL_ERROR",    "Ocorreu um erro inesperado.",    undefined,    requestId ? { requestId } : undefined  );}

Ordem de registro no app

O handler deve ser o último middleware após rotas.

import express from "express";import { errorHandler } from "./error-handler";const app = express();app.use(express.json());// ... rotas aquiapp.use(errorHandler);

Passo a passo: aplicando em um endpoint

1) Serviço lança erros tipados

O serviço não deve conhecer Express. Ele retorna dados ou lança AppError (ou subclasses).

import { DomainError, NotFoundError, ConflictError } from "../errors";type PayOrderInput = { orderId: string; userId: string };type PayOrderOutput = { orderId: string; status: "PAID" };export async function payOrder(input: PayOrderInput): Promise<PayOrderOutput> {  const order = await findOrderById(input.orderId);  if (!order) throw new NotFoundError("Pedido não encontrado");  if (order.userId !== input.userId) {    // não vaze informação se o pedido existe para outro usuário    throw new NotFoundError("Pedido não encontrado");  }  if (order.status === "PAID") {    throw new ConflictError("Pedido já está pago", { currentStatus: order.status });  }  if (order.total > order.userBalance) {    throw new DomainError("INSUFFICIENT_BALANCE", "Saldo insuficiente", {      required: order.total,      available: order.userBalance    });  }  await markOrderAsPaid(order.id);  return { orderId: order.id, status: "PAID" };}

2) Controller usa helper de sucesso e delega erros ao handler

import { Request, Response, NextFunction } from "express";import { sendOk } from "../http/response-helpers";import { payOrder } from "../services/pay-order";export async function payOrderController(req: Request, res: Response, next: NextFunction) {  try {    const userId = String((req as any).user?.id); // supondo auth middleware anterior    const orderId = String(req.params.orderId);    const result = await payOrder({ orderId, userId });    return sendOk(res, result, 200);  } catch (err) {    return next(err);  }}

Esse padrão garante que qualquer exceção caia no errorHandler e mantenha o contrato.

Preservando stack trace e evitando vazamento de dados

Boas práticas

  • Nunca retorne err.message diretamente para o cliente em erros desconhecidos.
  • Use publicMessage para o cliente e message/stack para logs.
  • Inclua requestId em meta para correlação entre logs e resposta.
  • Em produção, evite enviar details em erros que possam revelar estrutura interna (ex.: SQL, nomes de tabelas, paths).
  • Ao encapsular um erro original, use cause (ou armazene em campo) para não perder contexto.

Exemplo: encapsulando erro de infraestrutura sem expor detalhes

import { AppError } from "../http/errors";export class ExternalServiceError extends AppError {  constructor(cause?: unknown) {    super("External service call failed", {      code: "EXTERNAL_SERVICE_ERROR",      status: 502,      publicMessage: "Falha ao comunicar com serviço externo.",      cause    });  }}

Exemplos de responses (cenários reais)

1) GET /users/123 (sucesso)

{  "ok": true,  "data": {    "id": "123",    "name": "Ana",    "email": "ana@exemplo.com"  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

2) POST /orders (validação 400)

{  "ok": false,  "error": {    "code": "VALIDATION_ERROR",    "message": "Dados inválidos",    "details": {      "fields": [        { "path": "items[0].quantity", "message": "Deve ser maior que zero" },        { "path": "address.zip", "message": "Formato inválido" }      ]    }  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

3) GET /orders/999 (não encontrado 404)

{  "ok": false,  "error": {    "code": "RESOURCE_NOT_FOUND",    "message": "Recurso não encontrado"  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

4) POST /auth/login (não autenticado 401)

{  "ok": false,  "error": {    "code": "AUTH_UNAUTHENTICATED",    "message": "Credenciais inválidas"  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

5) POST /orders/123/pay (regra de domínio 422)

{  "ok": false,  "error": {    "code": "INSUFFICIENT_BALANCE",    "message": "Saldo insuficiente",    "details": { "required": 199.9, "available": 50 }  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

6) Erro inesperado 500 (sem vazamento)

{  "ok": false,  "error": {    "code": "INTERNAL_ERROR",    "message": "Ocorreu um erro inesperado."  },  "meta": {    "requestId": "c2f1d9a0-1a2b-4b1a-9a9a-2d2b2f8c0a10"  }}

Checklist de conformidade para novos endpoints

  • O endpoint retorna sempre o contrato { ok, data|error, meta? }?
  • Os status codes estão coerentes com o cenário (200/201/400/401/403/404/409/422/500)?
  • Erros de domínio são lançados como DomainError (ou subclasses) com code interno estável?
  • Erros de validação retornam VALIDATION_ERROR e details por campo (quando aplicável)?
  • Mensagens ao cliente usam publicMessage (não vazam stack, SQL, tokens, paths)?
  • O handler central registra logs com requestId, code, status e stack?
  • Erros desconhecidos viram 500 com INTERNAL_ERROR e mensagem genérica?
  • Controllers usam try/catch e next(err) (ou wrapper async) para não “engolir” exceções?
  • O error handler está registrado por último no Express?
  • O cliente consegue tomar decisão por error.code sem depender de texto?

Agora responda o exercício sobre o conteúdo:

Ao padronizar respostas e erros em uma API Express, qual abordagem melhor mantém as rotas e serviços independentes de HTTP e garante um contrato consistente?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A centralização no error handler permite que serviços lancem erros sem conhecer HTTP, enquanto o middleware padroniza status codes e o payload no contrato ({ ok, data|error, meta? }), evitando divergências entre endpoints.

Próximo capitúlo

Configuração por ambiente no Node.js: env vars, configuração tipada e segredos

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
50%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.