TypeScript com Express: tipando req, res e middlewares

1. Fundamentos: Instalação e Configuração Inicial

Para começar, instale os pacotes necessários:

npm init -y
npm install express
npm install -D typescript @types/express ts-node-dev

Configure o tsconfig.json para Node.js com Express:

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

Estrutura básica de pastas:

src/
├── controllers/
├── middlewares/
├── routes/
├── types/
│   ├── express.d.ts
│   ├── requests.ts
│   └── responses.ts
├── utils/
│   └── AppError.ts
└── index.ts

2. Tipando a Requisição (Request) e a Resposta (Response)

O Express fornece tipos nativos que podemos importar diretamente:

import express, { Request, Response, NextFunction } from 'express';

const app = express();
app.use(express.json());

Para tipar parâmetros de rota, query e body, usamos os genéricos do Request:

// Request<Params, ResBody, ReqBody, ReqQuery, Locals>
app.get('/users/:id', (req: Request<{ id: string }>, res: Response) => {
  const userId = req.params.id; // string
  res.json({ userId });
});

// Rota com query e body tipados
interface CreateUserBody {
  name: string;
  email: string;
  age?: number;
}

app.post('/users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
  const { name, email, age } = req.body;
  // TypeScript sabe que name e email são string, age é number | undefined
  res.status(201).json({ name, email, age });
});

3. Criando Tipos Customizados para Request e Response

Para adicionar propriedades customizadas ao req (como user, token, session), estendemos a interface global do Express:

// src/types/express.d.ts
declare namespace Express {
  interface Request {
    user?: {
      id: string;
      email: string;
      role: 'admin' | 'user';
    };
    token?: string;
  }
}

Agora podemos acessar req.user com segurança:

app.get('/profile', (req: Request, res: Response) => {
  if (req.user) {
    res.json({ user: req.user.email });
  } else {
    res.status(401).json({ error: 'Não autenticado' });
  }
});

Criando tipos reutilizáveis para respostas:

// src/types/responses.ts
export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  message?: string;
}

export interface ErrorResponse {
  success: false;
  error: string;
  statusCode: number;
}

export interface UserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

4. Tipando Middlewares de Forma Segura

Middleware simples com RequestHandler:

import { RequestHandler } from 'express';

const logger: RequestHandler = (req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next();
};

app.use(logger);

Middleware assíncrono com wrapper asyncHandler:

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<any>;

export const asyncHandler = (fn: AsyncRequestHandler): RequestHandler => {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

// Uso
app.get('/data', asyncHandler(async (req, res) => {
  const data = await fetchData();
  res.json(data);
}));

Middleware de autenticação populando req.user:

// src/middlewares/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

interface JwtPayload {
  id: string;
  email: string;
  role: 'admin' | 'user';
}

export const authenticate: RequestHandler = (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ error: 'Token não fornecido' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    req.user = { id: decoded.id, email: decoded.email, role: decoded.role };
    req.token = token;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Token inválido' });
  }
};

5. Tipagem de Erros e Tratamento Global

Criando classe AppError tipada:

// src/utils/AppError.ts
export class AppError extends Error {
  public readonly statusCode: number;
  public readonly isOperational: boolean;

  constructor(message: string, statusCode: number = 500) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

Middleware global de erro com type guards:

// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/AppError';

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof AppError) {
    // Erro operacional conhecido
    return res.status(err.statusCode).json({
      success: false,
      error: err.message,
      statusCode: err.statusCode,
    });
  }

  // Erro inesperado (programação)
  console.error('Erro não tratado:', err);
  return res.status(500).json({
    success: false,
    error: 'Erro interno do servidor',
    statusCode: 500,
  });
};

// Uso no app
app.use(errorHandler);

6. Boas Práticas com Interfaces e Types Utilitários

Usando Partial, Pick, Omit para tipar req.body em rotas PATCH/PUT:

// src/types/requests.ts
export interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
  role?: 'admin' | 'user';
}

export interface UpdateUserRequest {
  name?: string;
  email?: string;
  password?: string;
}

// Ou usando Partial
type UpdateUserRequestPartial = Partial<CreateUserRequest>;

// Rota PATCH com Pick
app.patch('/users/:id', (req: Request<{ id: string }, {}, Partial<CreateUserRequest>>, res: Response) => {
  const updates = req.body;
  // TypeScript infere que updates pode ter qualquer propriedade opcional de CreateUserRequest
  res.json({ updated: updates });
});

Separando tipos em arquivos dedicados:

// types/requests.ts
export interface CreateProductRequest {
  name: string;
  price: number;
  category: string;
  stock: number;
}

export interface UpdateProductRequest {
  name?: string;
  price?: number;
  category?: string;
  stock?: number;
}

// types/responses.ts
export interface ProductResponse {
  id: string;
  name: string;
  price: number;
  category: string;
  stock: number;
  createdAt: Date;
  updatedAt: Date;
}

7. Exemplo Completo: CRUD de Usuários com Tipagem

// src/controllers/userController.ts
import { Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { AppError } from '../utils/AppError';
import { CreateUserRequest, UpdateUserRequest } from '../types/requests';
import { UserResponse } from '../types/responses';

// Simulação de banco de dados
const users: UserResponse[] = [];

// POST /users - Criar usuário
export const createUser = asyncHandler(async (
  req: Request<{}, {}, CreateUserRequest>,
  res: Response<UserResponse>
) => {
  const { name, email, password, role } = req.body;

  // Validação básica
  if (!name || !email || !password) {
    throw new AppError('Campos obrigatórios: name, email, password', 400);
  }

  const newUser: UserResponse = {
    id: String(users.length + 1),
    name,
    email,
    createdAt: new Date(),
  };

  users.push(newUser);
  res.status(201).json(newUser);
});

// GET /users/:id - Buscar usuário por ID
export const getUserById = asyncHandler(async (
  req: Request<{ id: string }>,
  res: Response<UserResponse>
) => {
  const user = users.find(u => u.id === req.params.id);

  if (!user) {
    throw new AppError('Usuário não encontrado', 404);
  }

  res.json(user);
});

// PATCH /users/:id - Atualizar usuário
export const updateUser = asyncHandler(async (
  req: Request<{ id: string }, {}, UpdateUserRequest>,
  res: Response<UserResponse>
) => {
  const userIndex = users.findIndex(u => u.id === req.params.id);

  if (userIndex === -1) {
    throw new AppError('Usuário não encontrado', 404);
  }

  users[userIndex] = { ...users[userIndex], ...req.body };
  res.json(users[userIndex]);
});

// DELETE /users/:id - Deletar usuário
export const deleteUser = asyncHandler(async (
  req: Request<{ id: string }>,
  res: Response<{ message: string }>
) => {
  const userIndex = users.findIndex(u => u.id === req.params.id);

  if (userIndex === -1) {
    throw new AppError('Usuário não encontrado', 404);
  }

  users.splice(userIndex, 1);
  res.json({ message: 'Usuário deletado com sucesso' });
});

Rotas com middlewares de autenticação e autorização:

// src/routes/userRoutes.ts
import { Router } from 'express';
import { authenticate } from '../middlewares/auth';
import { authorize } from '../middlewares/authorize';
import * as userController from '../controllers/userController';

const router = Router();

// Rotas públicas
router.post('/users', userController.createUser);

// Rotas protegidas (qualquer usuário autenticado)
router.get('/users/:id', authenticate, userController.getUserById);
router.patch('/users/:id', authenticate, userController.updateUser);
router.delete('/users/:id', authenticate, userController.deleteUser);

// Rota apenas para admin
router.get('/admin/users', authenticate, authorize('admin'), (req, res) => {
  res.json({ users });
});

export default router;

Middleware de autorização:

// src/middlewares/authorize.ts
import { RequestHandler } from 'express';
import { AppError } from '../utils/AppError';

export const authorize = (...allowedRoles: string[]): RequestHandler => {
  return (req, res, next) => {
    if (!req.user) {
      throw new AppError('Não autenticado', 401);
    }

    if (!allowedRoles.includes(req.user.role)) {
      throw new AppError('Acesso não autorizado', 403);
    }

    next();
  };
};

Arquivo principal index.ts:

import express from 'express';
import userRoutes from './routes/userRoutes';
import { errorHandler } from './middlewares/errorHandler';

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

app.use(express.json());
app.use('/api', userRoutes);
app.use(errorHandler);

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

Referências