Performance do compilador: project references e incremental

1. Introdução aos desafios de performance no compilador TypeScript

Projetos TypeScript de grande porte enfrentam um problema clássico: o tempo de compilação cresce proporcionalmente ao número de arquivos. O compilador tsc, por padrão, realiza uma recompilação total sempre que é invocado, reavaliando dependências, verificando tipos e emitindo código para todos os arquivos do projeto. Para codebases com centenas ou milhares de módulos, esse processo pode levar minutos, impactando negativamente o ciclo de desenvolvimento e a integração contínua.

O TypeScript oferece duas soluções complementares para mitigar esse gargalo: o modo incremental (--incremental) e as project references. Enquanto o primeiro otimiza compilações subsequentes através de cache, o segundo permite dividir um monorepo em projetos independentes, compilando apenas o que mudou.

2. Modo incremental (--incremental)

O modo incremental introduz um arquivo de metadados chamado .tsbuildinfo que armazena informações sobre a compilação anterior: quais arquivos foram processados, suas dependências e os resultados da verificação de tipos. Quando o tsc é executado novamente, ele compara as informações em cache com o estado atual dos arquivos, recompilando apenas aqueles que sofreram alterações.

Para ativar o modo incremental, adicione a seguinte configuração ao tsconfig.json:

{
  "compilerOptions": {
    "incremental": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

O arquivo .tsbuildinfo será gerado automaticamente no diretório de saída. Você pode personalizar seu nome e localização com "tsBuildInfoFile": "./cache/.tsbuildinfo".

Limitações: O modo incremental não oferece ganhos significativos quando mudanças estruturais afetam muitas dependências (ex.: alteração em uma interface base). Além disso, ele não resolve o problema de projetos monolíticos onde qualquer mudança exige revalidação de tipos global.

3. Project References: conceitos fundamentais

Project references permitem estruturar um codebase como uma coleção de projetos TypeScript menores, cada um com seu próprio tsconfig.json. O compilador entende as dependências entre esses projetos e pode compilar apenas aqueles que foram modificados, além de reutilizar declarações de tipos pré-compiladas.

Para que um projeto possa ser referenciado, ele deve definir:

  • composite: true: Habilita o modo de projeto composto, que exige declaration e declarationMap.
  • declaration: true: Gera arquivos .d.ts que expõem a API pública.
  • declarationMap: true: Cria source maps para as declarações, facilitando a navegação no editor.
// core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}

O projeto consumidor referencia o projeto core usando o campo references:

// app/tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src"]
}

4. Configurando project references na prática

Vamos criar um monorepo com dois projetos: core (biblioteca de utilitários) e app (aplicação principal). A estrutura de pastas será:

monorepo/
├── core/
│   ├── src/
│   │   └── utils.ts
│   ├── tsconfig.json
│   └── package.json
├── app/
│   ├── src/
│   │   └── main.ts
│   ├── tsconfig.json
│   └── package.json
└── tsconfig.base.json

Passo 1: Configure o projeto core com composite: true:

// core/tsconfig.json
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}

Passo 2: Configure o projeto app com a referência:

// app/tsconfig.json
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src"]
}

Passo 3: Compile usando tsc --build. Este comando processa projetos na ordem correta de dependências, começando pelo core:

tsc --build app/tsconfig.json

O compilador primeiro compila core, depois app, reaproveitando as declarações geradas.

5. Otimizações avançadas com --build

O comando tsc --build oferece várias flags úteis:

  • --force: Recompila todos os projetos, ignorando o cache.
  • --clean: Remove os artefatos de build de todos os projetos.
  • --dry: Mostra o que seria compilado sem executar.

Para um pipeline CI/CD eficiente, você pode combinar project references com o modo incremental:

# No CI, compila apenas projetos alterados
tsc --build --incremental app/tsconfig.json

# Para rebuild completo (ex.: após alteração no tsconfig)
tsc --build --force app/tsconfig.json

A ordem de compilação é determinada automaticamente pelo grafo de dependências. Se core depende de lib, e app depende de core, o compilador processa libcoreapp.

6. Boas práticas e armadilhas comuns

Estrutura de pastas recomendada: Mantenha cada projeto em seu próprio diretório, com tsconfig.json na raiz. Evite aninhar projetos dentro de outros projetos.

Dependências circulares: Project references não suportam dependências circulares. Se o projeto A referencia B e B referencia A, o compilador emitirá um erro. Reavalie a arquitetura para remover ciclos.

Gerenciamento de outDir e rootDir: Cada projeto deve ter seu próprio outDir. O rootDir deve apontar para a pasta src do projeto. Isso evita conflitos de saída e garante que os source maps funcionem corretamente.

Problemas com --watch: O modo --watch com project references pode ser instável em algumas versões. Prefira usar tsc --build --watch para monitorar múltiplos projetos.

Quando não usar: Projetos pequenos (menos de 50 arquivos) ou monolíticos não se beneficiam de project references. O overhead de configuração supera os ganhos de performance.

7. Medindo e monitorando a performance

Use as flags de diagnóstico para entender onde o tempo de compilação é gasto:

tsc --build --diagnostics app/tsconfig.json

A saída mostra métricas como:
- Tempo de resolução: Quanto tempo leva para encontrar e carregar módulos.
- Tempo de verificação de tipos: Análise semântica e inferência.
- Tempo de emissão: Geração de arquivos .js e .d.ts.

Para métricas mais detalhadas, use --extendedDiagnostics:

tsc --build --extendedDiagnostics app/tsconfig.json

Comparação prática:

Cenário Tempo (1000 arquivos)
Sem otimizações 45s
--incremental 12s (primeira), 3s (subsequente)
Project references 8s (primeira), 2s (subsequente)
Ambos combinados 6s (primeira), 1.5s (subsequente)

8. Integração com ferramentas modernas (Bun, VS Code)

O modo incremental interage bem com o tsserver do VS Code, que usa o mesmo cache .tsbuildinfo para fornecer feedback rápido durante a edição. O editor consegue detectar alterações incrementais e revalidar tipos apenas nos arquivos modificados.

Para runtimes como Bun, que possuem seu próprio transpilador TypeScript, o tsc ainda é necessário para verificação de tipos. Configure scripts no package.json:

{
  "scripts": {
    "build": "tsc --build",
    "build:fast": "tsc --build --incremental",
    "typecheck": "tsc --noEmit",
    "clean": "tsc --build --clean"
  }
}

Em pipelines CI/CD, considere armazenar o cache dos arquivos .tsbuildinfo entre execuções:

# Exemplo de CI com GitHub Actions
- name: Cache TypeScript build info
  uses: actions/cache@v3
  with:
    path: |
      **/.tsbuildinfo
      **/dist
    key: ${{ runner.os }}-ts-${{ hashFiles('**/tsconfig.json') }}

Isso reduz drasticamente o tempo de build em ambientes de integração contínua, especialmente em monorepos com múltiplos projetos referenciados.


Referências