Configurando tsconfig para um novo projeto TypeScript

1. Visão geral

O arquivo tsconfig.json define como o TypeScript valida tipos e compila o código. Se mal configurado, você pode enfrentar erros de importação em tempo de execução ou o compilador deixar passar muitos erros de tipo.

Este artigo constrói um tsconfig.json para um novo projeto Node.js do zero. Vamos ativar as flags de validação rigorosa, escolher o sistema de módulos correto e separar a configuração de build da configuração de testes. Ao terminar, você terá um conjunto de configuração pronto para usar e entenderá cada linha nela.

2. Inicializando o projeto

Crie um diretório e instale TypeScript como uma dependência de desenvolvimento:

mkdir order-service && cd order-service
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

O comando tsc --init gera um tsconfig.json com muitas linhas de comentários. Este arquivo é útil para consulta, mas difícil de manter. Vamos substituí-lo por uma versão concisa, com cada opção claramente documentada.

Em seguida, declare que o projeto usa ES Modules no package.json:

{
  "name": "order-service",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
}

A linha "type": "module" diz ao Node.js que os arquivos .js são ESM. Essa escolha afeta diretamente como o TypeScript resolve os módulos, como você verá na seção 3.

3. Configuração mínima para Node.js

Este é o tsconfig.json base:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],

    "rootDir": ".",
    "outDir": "dist",
    "sourceMap": true,
    "declaration": false,

    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

3.1. target, lib e types

  • target é a versão de JavaScript que o compilador emite. ES2022 é apropriado para Node.js 18+. Com esse target, async/await, optional chaining e class fields são preservados, não são degradados.
  • lib declara quais APIs estão disponíveis em tempo de execução. Sem "DOM", você não chamará acidentalmente document ou window no código do servidor.
  • types: ["node"] carrega apenas os tipos de Node.js. Se não declarado, TypeScript carrega automaticamente todos os pacotes em node_modules/@types. Isso às vezes traz tipos globais indesejados.

3.2. module e moduleResolution

Para um projeto que executa diretamente no Node.js, use NodeNext para ambas as opções. Neste modo, TypeScript simula corretamente como o Node.js resolve importações. Ele lê os campos "type" e "exports" em package.json e requer que importações relativas tenham extensões de arquivo.

Suponha que você tenha dois arquivos:

// src/order.ts
export interface Order {
  id: string;
  items: string[];
  note?: string;
}

export function totalItems(order: Order): number {
  return order.items.length;
}
// src/index.ts
import { totalItems } from "./order";

console.log(totalItems({ id: "A1", items: ["pho", "tra-da"] }));

Executar npx tsc --noEmit reportará um erro:

src/index.ts:1:28 - error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './order.js'?

Você precisa corrigir para import { totalItems } from "./order.js";. A extensão é .js e não .ts, porque em tempo de execução o Node.js lê o arquivo compilado. Esta regra parece estranha à primeira vista, mas ajuda você a evitar o erro ERR_MODULE_NOT_FOUND ao fazer deploy.

3.3. rootDir e outDir

outDir é o local onde os arquivos .js são colocados após o build. rootDir determina a estrutura do diretório dentro de outDir. No arquivo base, rootDir é "." porque include tem tanto src quanto tests. Na seção 6, o arquivo de build mudará rootDir para src para que dist/index.js fique no lugar certo em vez de dist/src/index.js.

4. Strict mode e flags de validação adicionais

A flag strict: true ativa um grupo de validações, incluindo strictNullChecks, noImplicitAny e strictFunctionTypes. Para um novo projeto, você sempre deve ativar essa flag. Ativá-la posteriormente em uma base de código grande custará muito mais esforço.

Além de strict, você deve adicionar as seguintes flags:

{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Você mescla essas linhas no bloco compilerOptions do arquivo base. As duas primeiras flags merecem uma explicação mais detalhada.

4.1. noUncheckedIndexedAccess

Por padrão, o TypeScript assume que elementos acessados por índice sempre existem. Na prática, isso não é verdade:

import type { Order } from "./order.js";

const ordersById: Record<string, Order> = {};

const order = ordersById["A1"];
console.log(order.id);

Quando essa flag é ativada, o tipo de order é Order | undefined e o compilador reporta:

error TS18048: 'order' is possibly 'undefined'.

Você é forçado a verificar antes de usar: if (order) { console.log(order.id); }. Se você desativar a flag, o código acima ainda será compilado, mas lançará um TypeError em tempo de execução.

4.2. exactOptionalPropertyTypes

Esta flag distingue “propriedade ausente” de “propriedade com valor undefined”:

import type { Order } from "./order.js";

const order: Order = { id: "A2", items: [], note: undefined };
error TS2375: Type '{ id: string; items: never[]; note: undefined; }' is not assignable
to type 'Order' with 'exactOptionalPropertyTypes: true'.

Essa distinção é importante quando você usa Object.keys, o operador in ou serializa para JSON. Se você realmente quer permitir undefined, declare explicitamente: note?: string | undefined.

Nota: exactOptionalPropertyTypes às vezes entra em conflito com tipos de bibliotecas de terceiros, especialmente bibliotecas de manipulação de formulários e ORMs. Se essa flag causar muitos erros fora de seu controle, desative-a. Mas noUncheckedIndexedAccess deve ser mantida.

5. Interoperabilidade entre módulos

Adicione as duas flags a seguir em compilerOptions:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

verbatimModuleSyntax tem uma regra simples: qualquer import sem a palavra-chave type é mantido na saída, enquanto imports com type são removidos. Assim, o resultado da compilação é previsível. Em troca, você deve marcar explicitamente imports que são apenas para tipos:

// Errado: Order é apenas uma interface
import { Order } from "./order.js";
error TS1484: 'Order' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.
// Correto
import type { Order } from "./order.js";
import { totalItems } from "./order.js";

isolatedModules garante que cada arquivo possa ser compilado independentemente. Ferramentas como esbuild, swc e Vitest processam um arquivo por vez, então essa flag ajuda você a detectar erros antes que eles causem problemas.

Você pode ter visto esModuleInterop em muitos guias antigos. Com module: NodeNext, essa flag é ativada por padrão, então você não precisa declarar explicitamente.

6. Separando configurações de typecheck e build

O arquivo tsconfig.json base serve para o editor e o comando typecheck. Inclui tests. Quando você faz build, não quer incluir arquivos de teste em dist. Portanto, crie um arquivo adicional tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": false
  },
  "include": ["src"],
  "exclude": ["**/*.test.ts"]
}

extends herda todos os compilerOptions do arquivo base. No arquivo filho, include e exclude substituem completamente os valores do arquivo pai, eles não se mesclam. Execute o comando de build:

npm run build
ls dist
index.js  index.js.map  order.js  order.js.map

O diretório dist contém apenas código da aplicação, colocado na raiz graças a rootDir: "src".

6.1. Escrevendo testes com Vitest

Instale o Vitest:

npm install --save-dev vitest

Adicione "test": "vitest run" ao scripts, então escreva testes para a função totalItems:

// tests/order.test.ts
import { describe, expect, it } from "vitest";
import type { Order } from "../src/order.js";
import { totalItems } from "../src/order.js";

describe("totalItems", () => {
  it("conta corretamente o número de itens no pedido", () => {
    const order: Order = { id: "A1", items: ["pho", "tra-da", "quay"] };
    expect(totalItems(order)).toBe(3);
  });

  it("retorna 0 para um pedido vazio", () => {
    expect(totalItems({ id: "A2", items: [] })).toBe(0);
  });
});
npm run typecheck && npm test
 ✓ tests/order.test.ts (2 tests)
 Test Files  1 passed (1)
      Tests  2 passed (2)

O Vitest remove automaticamente tipos durante a execução sem verificá-los. Portanto, sempre execute typecheck no CI. Caso contrário, um teste com erro de tipo ainda pode “passar”.

7. Variante para projetos com bundler

Se seu código é empacotado por Vite, esbuild ou webpack em vez de executar diretamente no Node.js, altere a seção de módulos assim:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "jsx": "react-jsx"
  }
}
  • moduleResolution: "Bundler" permite imports sem extensões de arquivo, já que o bundler faz a resolução.
  • noEmit: true significa que tsc apenas valida tipos, enquanto o bundler cuida de emitir os arquivos.
  • Adicione "DOM" porque o código executa no navegador.
  • A linha jsx é necessária apenas se você usar React.

Nota: Não use Bundler para código que executa diretamente com node dist/index.js. O compilador aceitará imports sem a extensão .js, mas o Node.js reportará um erro em tempo de execução.

8. Erros comuns

Usar paths para criar aliases e depois fazer build com tsc. Suponha que você declare "paths": { "@/*": ["src/*"] }. O editor entende esse alias, mas tsc não reescreve os caminhos na saída, então node não encontrará @/order.js. Para um projeto Node.js, use subpath imports em package.json:

{
  "imports": {
    "#src/*": "./dist/*"
  }
}

Depois, você importa usando import { totalItems } from "#src/order.js";. No modo NodeNext, o TypeScript lê o campo imports e mapeia de volta de dist para src, então tanto o editor quanto o runtime resolvem corretamente.

Usar skipLibCheck em excesso. Essa flag apenas ignora a verificação de arquivos .d.ts, ajudando a compilar mais rápido e evitando erros dos tipos de bibliotecas. Isso não torna seu código menos seguro. No entanto, se você escrever seus próprios arquivos .d.ts, lembre-se que erros nesses próprios arquivos também serão ignorados.

Esquecer include. Sem include, o TypeScript varre todo o diretório, incluindo dist. Isso geralmente resulta no erro TS5055: Cannot write file ... because it would overwrite input file.

Misturar CommonJS e ESM. Se seu package.json não tem "type": "module" mas você usa NodeNext, seus arquivos .ts serão compilados para CommonJS. Nesse caso, importar um pacote que só suporta ESM causará um erro. Escolha uma direção desde o início. Para um novo projeto, escolha ESM.

Ativar strict e espalhando any por toda parte. Fazer isso anula o efeito do strict mode. Quando você não souber o tipo dos dados, use unknown e depois refine o tipo com verificações de tipo.

9. Conclusão

Este artigo construiu um tsconfig.json para um novo projeto Node.js com as seguintes partes:

  • module e moduleResolution definidos como NodeNext, juntamente com "type": "module" para que o compilador simule corretamente o runtime.
  • strict junto com as flags noUncheckedIndexedAccess e exactOptionalPropertyTypes para capturar erros cedo.
  • verbatimModuleSyntax e isolatedModules para saída previsível e compatibilidade com ferramentas de compilação rápida.
  • Um arquivo tsconfig.build.json separado para que dist contenha apenas código da aplicação.

Se seu projeto usa bundler, você apenas precisa mudar para moduleResolution: "Bundler" e noEmit: true. As flags de validação rigorosa permanecem iguais. Finalmente, sempre execute tsc --noEmit no CI, pois a maioria das ferramentas de teste e bundlers não verificam tipos para você.

typescripttsconfignodejsconfiguracaovitest