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
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<Params, ResBody, ReqBody> 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
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.