Pular para o conteúdo
TypeScript

API REST Node.js com TypeScript

API REST em Node com TypeScript: tipagem de request/response e validação na borda.

Por que isso é importante

API REST com Node.js e TypeScript ganha quando request/response e erros são tipados na borda. Sem validação, o tipo do handler mente.

Por que TypeScript no backend Node.js

Galera, Node.js com JavaScript funciona. Mas quando o projeto cresce — mais rotas, mais regras, mais gente no time — a falta de tipos cobra caro. Alguém muda o nome de um campo no banco e a rota continua mandando o campo antigo. Ninguém percebe até o cliente reclamar.

TypeScript resolve isso na raiz. O tipo do request.body, dos params, do response — tudo definido. Mudou o campo? O compilador mostra todos os arquivos que precisam de ajuste. Simples assim.

Passo a passo: setup do projeto

  1. Inicie o projeto Node.js — npm init -y e instale express, typescript, ts-node-dev, e os @types necessários.
  2. Configure o tsconfig.json — Defina outDir, rootDir, strict: true e module: commonjs ou ESNext.
  3. Crie a estrutura de pastas — src/ com routes/, controllers/, interfaces/ e middlewares/.
  4. Configure o script de dev — ts-node-dev com --respawn pra hot reload durante desenvolvimento.
  5. Crie o server.ts — Arquivo principal que inicializa Express, aplica middlewares e registra rotas.

Setup inicial: Express com TypeScript

Primeiro, as dependências e a configuração base. O @types/express traz todas as tipagens que o Express precisa.

// Terminal: instalar dependências
// npm init -y
// npm i express
// npm i -D typescript ts-node-dev @types/express @types/node

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

// package.json scripts
// "dev": "ts-node-dev --respawn --transpile-only src/server.ts"
// "build": "tsc"
// "start": "node dist/server.js"

Server.ts: ponto de entrada tipado

import express, { Application } from "express";
import { userRouter } from "./routes/user.routes";

const app: Application = express();
const PORT = process.env.PORT || 3000;

// Middlewares globais
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Rotas
app.use("/api/users", userRouter);

// Health check
app.get("/health", (_req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server rodando na porta ${PORT}`);
});

export default app;

O Application do Express já vem tipado. O _req com underscore indica que não usamos o parâmetro — convenção limpa pra TypeScript não reclamar de variável não usada.

Interfaces: o contrato da API

Antes de criar rotas, defina as interfaces. Elas representam o shape dos dados que entram e saem da API.

// src/interfaces/user.interface.ts

export interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "user";
  createdAt: Date;
}

// Tipo pra criação (sem id e createdAt)
export interface CreateUserDTO {
  name: string;
  email: string;
  role?: "admin" | "user";
}

// Tipo pra atualização (tudo opcional)
export interface UpdateUserDTO {
  name?: string;
  email?: string;
  role?: "admin" | "user";
}

// Response padrão da API
export interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

// Params tipados
export interface UserParams {
  id: string;
}

O CreateUserDTO não tem id porque o banco gera. O UpdateUserDTO tem campos opcionais porque atualização parcial é o padrão. Esses DTOs viram o tipo do req.body em cada rota.

Controller tipado: CRUD completo

O controller recebe Request e Response do Express e usa generics pra tipar body, params e response. Cada handler sabe exatamente o que esperar.

// src/controllers/user.controller.ts
import { Request, Response } from "express";
import {
  User,
  CreateUserDTO,
  UpdateUserDTO,
  ApiResponse,
  UserParams,
} from "../interfaces/user.interface";

// Simulando banco de dados em memória
let users: User[] = [];

export const UserController = {
  // GET /api/users
  list: (_req: Request, res: Response<ApiResponse<User[]>>) => {
    res.json({ success: true, data: users });
  },

  // GET /api/users/:id
  getById: (
    req: Request<UserParams>,
    res: Response<ApiResponse<User | null>>
  ) => {
    const user = users.find((u) => u.id === req.params.id);
    if (!user) {
      return res.status(404).json({
        success: false,
        data: null,
        message: "Usuário não encontrado",
      });
    }
    res.json({ success: true, data: user });
  },

  // POST /api/users
  create: (
    req: Request<{}, {}, CreateUserDTO>,
    res: Response<ApiResponse<User>>
  ) => {
    const { name, email, role } = req.body;
    const newUser: User = {
      id: Date.now().toString(),
      name,
      email,
      role: role || "user",
      createdAt: new Date(),
    };
    users.push(newUser);
    res.status(201).json({ success: true, data: newUser });
  },

  // PUT /api/users/:id
  update: (
    req: Request<UserParams, {}, UpdateUserDTO>,
    res: Response<ApiResponse<User | null>>
  ) => {
    const index = users.findIndex((u) => u.id === req.params.id);
    if (index === -1) {
      return res.status(404).json({
        success: false,
        data: null,
        message: "Usuário não encontrado",
      });
    }
    users[index] = { ...users[index], ...req.body };
    res.json({ success: true, data: users[index] });
  },

  // DELETE /api/users/:id
  delete: (
    req: Request<UserParams>,
    res: Response<ApiResponse<null>>
  ) => {
    users = users.filter((u) => u.id !== req.params.id);
    res.json({ success: true, data: null, message: "Removido" });
  },
};

O ponto-chave: Request aceita 3 generics — Params, ResBody, ReqBody. Isso garante que req.body é CreateUserDTO no POST e UpdateUserDTO no PUT. Acesso a campo errado? O compilador avisa.

Rotas tipadas com Express Router

// src/routes/user.routes.ts
import { Router } from "express";
import { UserController } from "../controllers/user.controller";

const userRouter = Router();

userRouter.get("/", UserController.list);
userRouter.get("/:id", UserController.getById);
userRouter.post("/", UserController.create);
userRouter.put("/:id", UserController.update);
userRouter.delete("/:id", UserController.delete);

export { userRouter };

Rotas limpas. Cada handler no controller já sabe o tipo dos dados que vai receber. O arquivo de rotas fica só com a responsabilidade de mapear URL pra controller.

Middleware de validação tipado

Um middleware que valida o body antes de chegar no controller. Se o dado não bate com o esperado, retorna 400 antes de processar.

// src/middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from "express";

type ValidatorFn = (body: unknown) => string | null;

export function validateBody(validator: ValidatorFn) {
  return (req: Request, res: Response, next: NextFunction) => {
    const error = validator(req.body);
    if (error) {
      return res.status(400).json({
        success: false,
        data: null,
        message: error,
      });
    }
    next();
  };
}

// Validador específico pra criação de user
export function validateCreateUser(body: unknown): string | null {
  const data = body as Record<string, unknown>;
  if (!data.name || typeof data.name !== "string") {
    return "Campo name é obrigatório e deve ser string";
  }
  if (!data.email || typeof data.email !== "string") {
    return "Campo email é obrigatório e deve ser string";
  }
  return null;
}

// Uso na rota:
// userRouter.post("/", validateBody(validateCreateUser), UserController.create);

Erros comuns ao criar API com TypeScript

Esquecer de instalar @types/express: sem ele, Request e Response ficam como 'any' e a tipagem toda se perde.

Não tipar req.body: sem generic, body é 'any'. Sempre use Request nos handlers.

Usar 'as' pra forçar tipos: req.body as CreateUserDTO esconde campos faltando. Valide antes de converter.

strict: false no tsconfig: sem strict, TypeScript aceita várias falhas silenciosas. Sempre strict: true.

Não separar interfaces em arquivo próprio: misturar tipos com lógica dificulta reuso e manutenção.

Checklist: API REST com Node.js e TypeScript

Checklist Final

  • TypeScript e @types instalados (express, node)
  • tsconfig.json com strict: true configurado
  • Estrutura de pastas organizada (routes, controllers, interfaces, middlewares)
  • Interfaces criadas pra entidade, DTOs e response padrão
  • Controllers com Request e Response tipados via generics
  • Rotas mapeando URL pra controller
  • Middleware de validação tipado
  • Script de dev com ts-node-dev configurado
  • Build gerando JavaScript em dist/

Construa uma API profissional do zero

Essa foi a base. No CrazyStack, você vai além: autenticação JWT, banco de dados com Prisma, middleware de permissões, testes automatizados — tudo com TypeScript de ponta a ponta num projeto completo que funciona em produção.