Configuração do TypeScript para Node.js (tsconfig) com foco em back-end
Em um back-end Node.js, o TypeScript traz ergonomia (autocompletar, refactors seguros, contratos entre camadas) e reduz erros comuns ao explicitar formatos de dados e dependências. Para isso funcionar bem, a configuração do compilador precisa alinhar: alvo de runtime, resolução de módulos, geração de source maps e organização de pastas (src/dist).
Estrutura recomendada de pastas
src/: código TypeScriptdist/: saída JavaScript compiladasrc/types/ousrc/@types/: tipos compartilhados (opcional)src/modules/(ousrc/features/): agrupamento por domínio/feature
tsconfig.json: opções essenciais
Um tsconfig.json bem ajustado evita fricção no dia a dia e melhora a qualidade do build. Abaixo há um exemplo prático para um back-end moderno (Node atual) com saída em dist, source maps e suporte a aliases via paths.
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "./src", "outDir": "./dist", "strict": true, "noImplicitAny": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "sourceMap": true, "inlineSources": true, "declaration": false, "baseUrl": "./src", "paths": { "@/*": ["*"] } }, "include": ["src"], "exclude": ["node_modules", "dist"]}Pontos importantes:
strict: ativa um conjunto de verificações que reduz “surpresas” em produção.module/moduleResolutioncomoNodeNext: melhora compatibilidade com ESM e resolução moderna.rootDir/outDir: separa fonte e build, facilitando deploy e debug.sourceMap+inlineSources: permite debugar no TypeScript (stack traces apontando para.ts).baseUrl+paths: cria aliases como@/...para imports mais limpos.noUncheckedIndexedAccess: força cuidado ao acessar índices/chaves que podem não existir (muito útil com objetos vindos de request).
Configuração de build e execução em desenvolvimento
Você normalmente terá dois fluxos: (1) desenvolvimento com recarregamento e stack trace legível; (2) build para produção.
Passo a passo: scripts no package.json
{ "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", "typecheck": "tsc -p tsconfig.json --noEmit" }}Notas:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
tsxexecuta TypeScript diretamente com boa experiência de DX (inclui source maps). Alternativas comuns incluemts-node, mastsxcostuma ser mais simples e rápido.typechecké útil em CI para validar tipos sem gerar build.
Source maps na prática (debug e erros)
Com sourceMap habilitado, erros em runtime tendem a apontar para linhas do TypeScript. Em produção, se você executar o JS compilado, mantenha os .map junto do dist (ou desabilite se não quiser expor fontes). Em desenvolvimento, ferramentas como tsx já integram bem esse fluxo.
Aliases (paths) e execução do código
paths é entendido pelo TypeScript no momento da compilação, mas o Node (em runtime) não resolve automaticamente esses aliases. Você tem três abordagens comuns:
- Evitar aliases e usar imports relativos (simples, porém verboso).
- Resolver aliases no build (reescrevendo imports no
dist). - Resolver aliases em runtime via loader/hook (mais comum em dev).
Uma opção prática é reescrever no build usando uma ferramenta como tsc-alias.
Passo a passo: paths com reescrita no build
{ "scripts": { "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/server.js" }}Assim, você pode importar:
import { UserService } from "@/modules/users/user.service";E o build ajusta para caminhos relativos no dist.
Conceitos de TypeScript aplicados ao back-end
Tipagem estrutural (por que isso importa em DTOs e camadas)
TypeScript usa tipagem estrutural: se um objeto “tem a forma” esperada, ele é compatível, mesmo que não seja instância de uma classe específica. Isso é excelente para back-end, onde dados chegam como JSON e você quer validar/transformar e então trabalhar com “formas” (interfaces/types).
type CreateUserDTO = { email: string; name: string;};function createUser(dto: CreateUserDTO) { // ...}Qualquer objeto com email e name (strings) é aceito. Isso facilita testes e integração entre camadas (controller → service → repository), mas exige atenção: TypeScript não valida runtime. Se o request vier com email: 123, o compilador não verá; por isso, validação runtime é parte do design (ver seção final).
Inferência de tipos (reduzindo anotações sem perder segurança)
O TypeScript infere tipos a partir de valores e retornos. Em back-end, isso ajuda a manter o código limpo, mas você deve anotar explicitamente onde há “fronteiras” (ex.: entrada de request, saída de repository, contratos públicos).
const DEFAULT_PAGE_SIZE = 20; // inferido como 20 (literal) ou number dependendo do contextoEm funções internas, a inferência costuma ser suficiente:
function normalizeEmail(email: string) { return email.trim().toLowerCase(); // retorno inferido como string}Em contratos entre camadas, prefira explicitar:
type User = { id: string; email: string; name: string; };type UserRepository = { findByEmail(email: string): Promise<User | null>;};Union types (tratando estados e erros de forma explícita)
Union types permitem representar alternativas: “ou isso, ou aquilo”. Em back-end, isso é útil para modelar resultados de operações (sucesso/erro), variações de payload e estados de autenticação.
Exemplo: resultado de serviço sem exceptions como fluxo principal
type ServiceError = | { type: "VALIDATION"; message: string; field?: string } | { type: "CONFLICT"; message: string } | { type: "NOT_FOUND"; message: string };type Ok<T> = { ok: true; value: T };type Err<E> = { ok: false; error: E };type Result<T, E> = Ok<T> | Err<E>;type User = { id: string; email: string; name: string };async function registerUser(input: { email: string; name: string }): Promise<Result<User, ServiceError>> { if (!input.email.includes("@")) { return { ok: false, error: { type: "VALIDATION", message: "Email inválido", field: "email" } }; } // ... return { ok: true, value: { id: "u_123", email: input.email, name: input.name } };}No controller, você faz narrowing pelo discriminante ok:
const result = await registerUser(req.body);if (!result.ok) { switch (result.error.type) { case "VALIDATION": return res.status(400).json(result.error); case "CONFLICT": return res.status(409).json(result.error); case "NOT_FOUND": return res.status(404).json(result.error); }}return res.status(201).json(result.value);Isso reduz any, evita try/catch para fluxo normal e torna os casos de erro visíveis no tipo.
Generics (tipos reutilizáveis para repositórios, paginação e respostas)
Generics permitem criar abstrações tipadas sem perder informação. Em back-end, aparecem muito em: repositórios, paginação, wrappers de resposta, caches e filas.
Exemplo: paginação genérica para endpoints
type PageRequest = { page: number; pageSize: number };type Page<T> = { items: T[]; page: number; pageSize: number; total: number;};type User = { id: string; email: string; name: string };async function listUsers(params: PageRequest): Promise<Page<User>> { // ... return { items: [], page: params.page, pageSize: params.pageSize, total: 0 };}O controller sabe exatamente o formato da resposta e o tipo de items.
Exemplo: repositório genérico com constraints
type Entity = { id: string };interface Repository<T extends Entity> { findById(id: string): Promise<T | null>; save(entity: T): Promise<T>;}type User = Entity & { email: string; name: string };class UserRepository implements Repository<User> { async findById(id: string) { return null; } async save(entity: User) { return entity; }}O constraint T extends Entity garante que toda entidade tem id, sem perder os campos específicos.
Modelando contratos entre camadas (DTOs, serviços e repositórios)
Onde tipar com mais rigor (boundaries)
Em back-end, os pontos que mais precisam de tipos explícitos são as “fronteiras”:
- Entrada HTTP:
req.body,req.query,req.params(sempre chegam como desconhecidos/strings). - Integrações: chamadas a outros serviços, filas, webhooks.
- Persistência: retorno de banco/ORM e mapeamento para o domínio.
- Contratos internos: interfaces de serviço e repositório.
Dentro de uma camada (ex.: funções puras de domínio), a inferência costuma ser suficiente e mantém o código mais leve.
DTOs: entrada/saída explícitas e separadas do domínio
DTO (Data Transfer Object) é o formato de dados que atravessa uma fronteira (ex.: HTTP). Evite reutilizar diretamente o tipo de domínio como “input” do controller, porque o domínio costuma exigir invariantes que a entrada ainda não garante.
// Entrada do endpoint (o que o cliente envia)type CreateUserInputDTO = { email: string; name: string;}; // Saída do endpoint (o que você expõe)type UserResponseDTO = { id: string; email: string; name: string;}; // Domínio (pode ter campos internos)type User = { id: string; email: string; name: string; passwordHash: string;};Separar esses tipos evita vazamento de campos sensíveis e reduz acoplamento.
Serviços: contratos claros e retornos previsíveis
Defina interfaces ou tipos para os serviços, especialmente quando eles serão usados por controllers diferentes ou testados com mocks.
type CreateUser = (input: CreateUserInputDTO) => Promise<Result<UserResponseDTO, ServiceError>>;Isso força o controller a lidar com todos os casos previstos no tipo.
Repositórios: interfaces para desacoplar persistência
Repositórios tipados permitem trocar implementação (ORM, SQL, memória) sem mudar o serviço. Use Promise<T | null> para ausências e evite retornar any.
type UserRecord = { id: string; email: string; name: string; password_hash: string };interface UsersRepo { findByEmail(email: string): Promise<UserRecord | null>; insert(data: { email: string; name: string; passwordHash: string }): Promise<UserRecord>;}Note a diferença entre UserRecord (forma do banco) e User (domínio). Um mapper tipado ajuda:
type User = { id: string; email: string; name: string; passwordHash: string };function toDomain(record: UserRecord): User { return { id: record.id, email: record.email, name: record.name, passwordHash: record.password_hash };}Reduzindo “any” com padrões práticos
Prefira unknown em entradas não confiáveis
Quando você não controla a origem do dado (HTTP, env vars, JSON externo), prefira unknown em vez de any. unknown obriga checagens antes do uso.
function parseJson(input: string): unknown { return JSON.parse(input);}Depois, você valida/narrowing:
type HealthPayload = { status: "ok" };function isHealthPayload(x: unknown): x is HealthPayload { return typeof x === "object" && x !== null && (x as any).status === "ok";}Use tipos utilitários para reaproveitar contratos
TypeScript oferece utilitários que ajudam a manter consistência:
Pick<T, K>: seleciona camposOmit<T, K>: remove camposPartial<T>: torna campos opcionais (use com cuidado em updates)Required<T>: torna campos obrigatóriosReturnType<F>eParameters<F>: reutiliza assinaturas
Exemplo comum: DTO de update que permite subset, mas não tudo:
type UserResponseDTO = { id: string; email: string; name: string };type UpdateUserInputDTO = Partial<Pick<UserResponseDTO, "email" | "name">>;Assim você não permite atualizar id por acidente.
Discriminated unions para eventos e comandos
Quando você tem múltiplos “tipos” de mensagem (ex.: eventos internos, jobs), use um campo discriminante para garantir exhaustiveness.
type Job = | { kind: "SEND_WELCOME_EMAIL"; userId: string } | { kind: "RECALC_STATS"; date: string };function handleJob(job: Job) { switch (job.kind) { case "SEND_WELCOME_EMAIL": return; case "RECALC_STATS": return; default: { const _exhaustive: never = job; return _exhaustive; } }}Se você adicionar um novo kind e esquecer de tratar, o TypeScript acusa.
Ergonomia: dicas para manter o TypeScript “amigável” no dia a dia
Evite tipos gigantes: componha tipos menores
Prefira compor tipos por partes (ex.: Id, Timestamps, Pagination) e reutilizar. Isso reduz duplicação e melhora legibilidade.
type WithId = { id: string };type WithTimestamps = { createdAt: Date; updatedAt: Date };type User = WithId & WithTimestamps & { email: string; name: string };Use “type” para shapes e “interface” para contratos extensíveis
Ambos funcionam bem, mas uma convenção prática:
typepara DTOs, unions e composições.interfacepara contratos de repositórios/serviços que podem ser implementados por classes.
Não force tipagem onde o compilador já sabe
Evite anotar tipos óbvios em variáveis locais; isso reduz ruído. Concentre anotações em funções públicas, retornos e fronteiras.
Organização de tipos, boundaries e quando preferir validação em runtime
Onde colocar tipos (organização)
- Por feature:
src/modules/users/users.types.ts,users.dto.ts,users.repo.ts. Mantém contexto próximo do uso. - Compartilhados:
src/types/result.ts,src/types/pagination.tspara utilitários genéricos usados em várias features. - Evite “types.ts” gigante: tende a virar um depósito difícil de manter.
Boundaries: o que tipar e o que validar
Tipagem estática ajuda a manter consistência do código que você controla. Já dados externos precisam de validação em runtime. Regras práticas:
- HTTP request: valide
body/query/paramsem runtime antes de converter para DTO tipado. - Env vars: valide e normalize na inicialização (ex.: porta numérica, URLs).
- Integrações: valide respostas de APIs externas (podem mudar ou falhar).
- Persistência: trate
null/undefinede inconsistências, especialmente em migrações.
Estratégias de validação runtime (sem acoplar o domínio)
Uma abordagem comum é: validar na borda, transformar para um tipo seguro e só então chamar o serviço.
type CreateUserInputDTO = { email: string; name: string };function parseCreateUserInput(body: unknown): CreateUserInputDTO { if (typeof body !== "object" || body === null) throw new Error("Body inválido"); const b = body as Record<string, unknown>; if (typeof b.email !== "string") throw new Error("email inválido"); if (typeof b.name !== "string") throw new Error("name inválido"); return { email: b.email, name: b.name };}Mesmo que você use uma biblioteca de validação, o princípio é o mesmo: na borda, converta de unknown → DTO seguro. A partir daí, o TypeScript consegue proteger o restante do fluxo.
Quando aceitar um pouco de flexibilidade
Nem tudo precisa ser genérico ou extremamente tipado. Prefira simplicidade quando:
- O código é local e não atravessa camadas (funções internas).
- O tipo mudará muito e ainda está em descoberta (protótipos).
- O custo de modelagem supera o benefício (ex.: payloads muito dinâmicos), desde que a validação runtime esteja cobrindo os riscos.