Node.js Essencial: TypeScript aplicado ao back-end (configuração e ergonomia)

Capítulo 4

Tempo estimado de leitura: 12 minutos

+ Exercício

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 TypeScript
  • dist/: saída JavaScript compilada
  • src/types/ ou src/@types/: tipos compartilhados (opcional)
  • src/modules/ (ou src/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/moduleResolution como NodeNext: 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:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • tsx executa TypeScript diretamente com boa experiência de DX (inclui source maps). Alternativas comuns incluem ts-node, mas tsx costuma 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 contexto

Em 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 campos
  • Omit<T, K>: remove campos
  • Partial<T>: torna campos opcionais (use com cuidado em updates)
  • Required<T>: torna campos obrigatórios
  • ReturnType<F> e Parameters<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:

  • type para DTOs, unions e composições.
  • interface para 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.ts para 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/params em 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/undefined e 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.

Agora responda o exercício sobre o conteúdo:

Ao configurar aliases de importação com baseUrl e paths (por exemplo, "@/...") em um back-end Node.js com TypeScript, qual é uma consideração correta sobre a execução do código?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

paths funciona para o TypeScript ao compilar, mas o runtime do Node.js não resolve aliases por padrão. Para executar, você precisa usar imports relativos, reescrever os imports no build (ex.: ferramenta de alias) ou configurar resolução em runtime.

Próximo capitúlo

Express com TypeScript: Fundamentos de roteamento e ciclo requisição-resposta

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
25%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.