Saltar para o conteúdo
TypeScript no Node.js: Configuração e Boas Práticas em 2024
Node.js

TypeScript no Node.js: Configuração e Boas Práticas em 2024

22 de abril de 2024·Paulo de Paula

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çãoPor quê
strict: trueAtiva 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: trueStack 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.ts

Para 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.ts

Utility 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.