TypeScript no Node.js: Configuração e Boas Práticas em 2024
TypeScript no Node.js tem mais atrito de configuração do que no frontend. Módulos, decorators, paths, build para produção — cada decisão afeta DX e performance do processo de build.
tsconfig.json para produção
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}Opções que mais importam:
| Opção | Por quê |
|---|---|
strict: true | Ativa strictNullChecks, noImplicitAny, etc. — pega bugs reais |
module: "NodeNext" | Suporte nativo a ESM com type: "module" no package.json |
moduleResolution: "NodeNext" | Resolve imports .js que apontam para .ts (padrão ESM) |
sourceMap: true | Stack traces apontam para .ts, não para .js compilado |
ESM vs CJS em 2024
// package.json — para ESM nativo
{
"type": "module",
"main": "dist/index.js"
}// Com ESM: imports precisam da extensão .js (mesmo sendo .ts)
import { PrismaClient } from '@prisma/client';
import { meuServico } from './servicos/meuServico.js'; // .js, não .ts!
// package.json — para CJS (mais compatível, mais fácil)
{
"type": "commonjs",
"main": "dist/index.js"
}ESM é o futuro, mas ainda tem atrito com algumas bibliotecas e com Jest (que requer configuração extra). Para projetos novos em 2024: use CJS se quiser zero atrito, ESM se estiver disposto a lidar com as bordas.
Desenvolvimento: tsx vs ts-node vs SWC
# tsx — mais rápido, sem configuração, recomendado
npm install -D tsx
tsx src/server.ts
tsx watch src/server.ts # hot reload
# ts-node — mais configurável, mais lento
npm install -D ts-node
ts-node src/server.ts
# SWC — o mais rápido (transpila sem checar tipos)
npm install -D @swc/core @swc/node
node --require @swc/register src/server.tsPara desenvolvimento, use tsx watch. Para CI/testes, use tsx ou ts-jest. Para produção, compile com tsc — types são verificados, output é JS puro.
Path aliases
// Sem alias: import relativo doloroso
import { UserService } from '../../../services/UserService';
// Com alias @/
import { UserService } from '@/services/UserService';Para o alias funcionar em runtime (não só no TypeScript), adicione:
npm install -D tsconfig-paths
# No script: ts-node -r tsconfig-paths/register src/server.ts
# Ou com tsx: tsx -r tsconfig-paths/register src/server.tsUtility types com exemplos reais
// Partial: todos os campos opcionais (DTOs de update)
type AtualizarUsuarioDto = Partial<Pick<Usuario, 'nome' | 'bio' | 'avatar'>>;
// Required: todos obrigatórios (após validação)
type UsuarioValidado = Required<AtualizarUsuarioDto>;
// Omit: exclui campos (não expor senha)
type UsuarioPublico = Omit<Usuario, 'senhaHash' | 'refreshTokens'>;
// ReturnType: infere tipo de retorno de função
async function buscarPosts() {
return prisma.post.findMany({ include: { autor: true } });
}
type PostComAutor = Awaited<ReturnType<typeof buscarPosts>>[number];
// Parameters: infere tipos dos parâmetros
function criarRelatorio(filtros: FiltroRelatorio, formato: 'pdf' | 'csv') { ... }
type CriarRelatorioArgs = Parameters<typeof criarRelatorio>;Branded types contra primitive obsession
// Sem branded types: qualquer string passa
function transferir(deContaId: string, paraContaId: string, valor: number) { ... }
transferir(paraContaId, deContaId, valor); // bugs silenciosos de ordem
// Com branded types: IDs de tipos diferentes não são intercambiáveis
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };
type ContaId = Brand<string, 'ContaId'>;
type UsuarioId = Brand<string, 'UsuarioId'>;
function criarContaId(id: string): ContaId { return id as ContaId; }
function criarUsuarioId(id: string): UsuarioId { return id as UsuarioId; }
function transferir(de: ContaId, para: ContaId, valor: number) { ... }
const contaA = criarContaId('conta-1');
const contaB = criarContaId('conta-2');
const usuId = criarUsuarioId('user-1');
transferir(contaA, contaB, 100); // ✅
transferir(contaA, usuId, 100); // ❌ Erro de compilação!
Estendendo o tipo Request do Express
// src/types/express.d.ts
import { JwtPayload } from './auth';
declare global {
namespace Express {
interface Request {
usuario?: JwtPayload;
requestId?: string;
}
}
}
// Agora em qualquer lugar:
app.get('/me', autenticar, (req, res) => {
// req.usuario tem tipos corretos, sem cast
res.json({ id: req.usuario?.sub });
});Build de produção com tsc + esbuild
# Build com verificação de tipos (lento mas correto)
npx tsc --noEmit # verifica tipos, sem output
npx tsc # gera JS em dist/
# Build rápido para CI com esbuild (sem checagem de tipos)
npm install -D esbuild
npx esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js// package.json
{
"scripts": {
"build": "tsc",
"build:fast": "esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts"
}
}Separe typecheck do build no CI: rode typecheck em paralelo com os testes, compile com build só antes do deploy. Isso evita que a verificação de tipos atrase o pipeline de testes.