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:
okcomo 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).metapode 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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ário | Status | Quando usar | Exemplo de code interno |
|---|---|---|---|
| Sucesso (GET/PUT/PATCH) | 200 | Operação concluída com retorno | - |
| Sucesso (POST criando) | 201 | Recurso criado | - |
| Sem conteúdo | 204 | Operação ok sem body (opcional; se usar contrato, prefira 200 com body) | - |
| Erro de validação | 400 | Payload inválido, regra de formato | VALIDATION_ERROR |
| Não autenticado | 401 | Token ausente/expirado/inválido | AUTH_UNAUTHENTICATED |
| Sem permissão | 403 | Autenticado, mas sem acesso | AUTH_FORBIDDEN |
| Não encontrado | 404 | Recurso inexistente | RESOURCE_NOT_FOUND |
| Conflito | 409 | Violação de unicidade/estado | RESOURCE_CONFLICT |
| Erro de domínio (regra de negócio) | 422 | Dados válidos, mas regra impede operação | DOMAIN_RULE_VIOLATION |
| Rate limit | 429 | Muitas requisições | RATE_LIMITED |
| Erro inesperado | 500 | Falha não tratada | INTERNAL_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.captureStackTracequando disponível). - Carregar status code e código interno.
- Ter uma mensagem segura para o cliente.
- Opcionalmente carregar
details(ex.: campos inválidos) ecause(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.messagediretamente para o cliente em erros desconhecidos. - Use
publicMessagepara o cliente emessage/stackpara logs. - Inclua
requestIdemmetapara correlação entre logs e resposta. - Em produção, evite enviar
detailsem 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) comcodeinterno estável? - Erros de validação retornam
VALIDATION_ERRORedetailspor campo (quando aplicável)? - Mensagens ao cliente usam
publicMessage(não vazam stack, SQL, tokens, paths)? - O handler central registra logs com
requestId,code,statusestack? - Erros desconhecidos viram
500comINTERNAL_ERRORe mensagem genérica? - Controllers usam
try/catchenext(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.codesem depender de texto?