Dominando Project References em TypeScript: Monorepos e Builds Incrementais em Projetos Reais Já leu

Project References: A Estrutura Fundamental do TypeScript Project References é um recurso do TypeScript que permite organizar grandes projetos em múltiplos sub-projetos (ou "workspaces"), onde cada um tem seu próprio arquivo . Diferente de módulos tradicionais, Project References estabelece uma relação de dependência explícita entre projetos TypeScript, permitindo que o compilador entenda a hierarquia e as dependências reais do seu código. Isso é crucial para manter projetos escaláveis e com build times reduzidos. Quando você trabalha sem Project References, o TypeScript compila todo o código de uma vez. Conforme seu projeto cresce, isso se torna ineficiente. Project References resolve esse problema criando uma estrutura onde cada projeto é compilado isoladamente, gerando arquivos (type definitions) que servem como contrato entre os projetos. O TypeScript então usa essas definições para verificar tipos sem recompilar código que não mudou. Como Project References Funciona O mecanismo é simples: você declara referências de projeto no usando a propriedade . Quando o TypeScript vê uma referência,

Project References: A Estrutura Fundamental do TypeScript

Project References é um recurso do TypeScript que permite organizar grandes projetos em múltiplos sub-projetos (ou "workspaces"), onde cada um tem seu próprio arquivo tsconfig.json. Diferente de módulos tradicionais, Project References estabelece uma relação de dependência explícita entre projetos TypeScript, permitindo que o compilador entenda a hierarquia e as dependências reais do seu código. Isso é crucial para manter projetos escaláveis e com build times reduzidos.

Quando você trabalha sem Project References, o TypeScript compila todo o código de uma vez. Conforme seu projeto cresce, isso se torna ineficiente. Project References resolve esse problema criando uma estrutura onde cada projeto é compilado isoladamente, gerando arquivos .d.ts (type definitions) que servem como contrato entre os projetos. O TypeScript então usa essas definições para verificar tipos sem recompilar código que não mudou.

Como Project References Funciona

O mecanismo é simples: você declara referências de projeto no tsconfig.json usando a propriedade references. Quando o TypeScript vê uma referência, ele cria um "módulo virtual" que aponta para as type definitions do projeto referenciado, em vez de recompilar seu código TypeScript. Isso permite que você tenha build paralelo e incremental — apenas os projetos que sofreram mudanças são recompilados.

Monorepos com TypeScript: Estrutura e Estratégia

Um monorepo é um repositório único que contém múltiplos projetos logicamente independentes. TypeScript com Project References é uma abordagem excelente para monorepos, pois oferece isolamento de tipos sem duplicação de código ou dependências circulares. A estrutura típica segue um padrão onde você tem uma pasta raiz com múltiplos pacotes (packages), cada um com seu próprio tsconfig.json e package.json.

A vantagem principal é que você consegue compartilhar código tipo uma biblioteca interna enquanto mantém cada projeto com suas próprias dependências e configurações de compilação. Ferramentas como Yarn Workspaces, npm Workspaces e pnpm gerenciam as dependências no nível do monorepo, enquanto Project References garante que o TypeScript entenda as relações de tipo entre os projetos.

Estrutura de Diretórios de um Monorepo

monorepo/
├── tsconfig.json                 # Config raiz (base)
├── package.json                  # Dependências compartilhadas
├── packages/
│   ├── core/
│   │   ├── tsconfig.json        # Projeto core
│   │   ├── package.json
│   │   └── src/
│   │       ├── math.ts
│   │       └── utils.ts
│   ├── api/
│   │   ├── tsconfig.json        # Depende de core
│   │   ├── package.json
│   │   └── src/
│   │       └── server.ts
│   └── cli/
│       ├── tsconfig.json        # Depende de core
│       ├── package.json
│       └── src/
│           └── index.ts
└── dist/

Configurando Project References

O arquivo tsconfig.json raiz serve como base para todos os projetos. Ele não compila código, apenas define configurações padrão que são herdadas pelos sub-projetos:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020"],
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "../dist"
  },
  "include": [],
  "references": []
}

Note que a raiz tem include e references vazios. Cada sub-projeto tem seu próprio tsconfig.json que estende a raiz e declara suas próprias referências:

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

Para o projeto api, que depende de core, você declara a referência explicitamente:

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

Builds Incrementais e Otimização de Performance

Builds incrementais significam que apenas o código que mudou é recompilado. Sem Project References, cada build recompila tudo. Com eles, você consegue rastrear quais projetos foram afetados pelas mudanças e recompilar apenas esses. O TypeScript faz isso usando timestamps dos arquivos .d.ts gerados.

Quando você altera um arquivo em core, o TypeScript regenera os .d.ts de core. Quando você compila api, o TypeScript detecta que as type definitions de core mudaram e recompila api. Mas se você compilar apenas core sem alterar nada, a próxima compilação de api será instantânea porque os .d.ts não mudaram.

Compilando com --build

Para aproveitar builds incrementais, use o flag --build do TypeScript em vez do modo normal:

tsc --build packages/core packages/api --verbose

Esse comando compila apenas o que é necessário, respeitando as dependências. Se você modificar algo em core, api será recompilado automaticamente. O --verbose mostra exatamente o que foi compilado, útil para debugar.

Você também pode limpar builds anteriores com --clean:

tsc --build --clean packages/core packages/api

Script de Build Incremental no package.json

{
  "scripts": {
    "build": "tsc --build packages/core packages/api packages/cli",
    "build:watch": "tsc --build --watch packages/core packages/api packages/cli",
    "build:clean": "tsc --build --clean packages/core packages/api packages/cli",
    "build:core": "tsc --build packages/core"
  }
}

O --watch é particularmente poderoso: mantém o processo rodando e recompila apenas os projetos afetados quando arquivos mudam. Isso torna o desenvolvimento extremamente rápido.

Exemplo Prático: Um Monorepo Real

Vamos criar um monorepo com três projetos: uma biblioteca de utilitários (core), um servidor API (api) e uma CLI (cli). O api e cli dependem de core.

Projeto Core - Utilitários Compartilhados

packages/core/src/math.ts:

export function add(a: number, b: number): number {
  return a + b;
}

export function multiply(a: number, b: number): number {
  return a * b;
}

export function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

packages/core/src/logger.ts:

export interface LoggerOptions {
  level: "info" | "warn" | "error";
  timestamp: boolean;
}

export class Logger {
  private options: LoggerOptions;

  constructor(options: LoggerOptions = { level: "info", timestamp: true }) {
    this.options = options;
  }

  log(message: string): void {
    const prefix = this.options.timestamp ? `[${new Date().toISOString()}]` : "";
    console.log(`${prefix} [${this.options.level}] ${message}`);
  }
}

packages/core/src/index.ts:

export * from "./math";
export * from "./logger";

packages/core/tsconfig.json:

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

Projeto API - Depende de Core

packages/api/src/server.ts:

import { add, multiply, Logger } from "@monorepo/core";

const logger = new Logger({ level: "info", timestamp: true });

export interface Request {
  operation: "add" | "multiply";
  a: number;
  b: number;
}

export function handleRequest(req: Request): number {
  logger.log(`Processing ${req.operation} operation`);

  switch (req.operation) {
    case "add":
      return add(req.a, req.b);
    case "multiply":
      return multiply(req.a, req.b);
    default:
      throw new Error(`Unknown operation: ${req.operation}`);
  }
}

packages/api/src/index.ts:

import { handleRequest, Request } from "./server";

const result = handleRequest({ operation: "add", a: 10, b: 5 });
console.log("Result:", result);

packages/api/tsconfig.json:

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

packages/api/package.json:

{
  "name": "@monorepo/api",
  "version": "1.0.0",
  "dependencies": {
    "@monorepo/core": "workspace:*"
  }
}

Projeto CLI - Também Depende de Core

packages/cli/src/commands.ts:

import { divide, Logger } from "@monorepo/core";

const logger = new Logger({ level: "info", timestamp: true });

export function executeCalculation(args: string[]): void {
  if (args.length < 3) {
    logger.log("Usage: cli <operation> <a> <b>");
    return;
  }

  const [operation, aStr, bStr] = args;
  const a = parseFloat(aStr);
  const b = parseFloat(bStr);

  if (isNaN(a) || isNaN(b)) {
    logger.log("Invalid numbers provided");
    return;
  }

  try {
    if (operation === "divide") {
      const result = divide(a, b);
      logger.log(`Result: ${result}`);
    } else {
      logger.log(`Unknown operation: ${operation}`);
    }
  } catch (error) {
    logger.log(`Error: ${(error as Error).message}`);
  }
}

packages/cli/src/index.ts:

import { executeCalculation } from "./commands";

const args = process.argv.slice(2);
executeCalculation(args);

packages/cli/tsconfig.json:

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

Configuração Raiz do Monorepo

tsconfig.json (raiz):

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@monorepo/core": ["packages/core/src"],
      "@monorepo/api": ["packages/api/src"],
      "@monorepo/cli": ["packages/cli/src"]
    }
  },
  "include": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/api" },
    { "path": "packages/cli" }
  ]
}

package.json (raiz):

{
  "name": "monorepo",
  "version": "1.0.0",
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "build": "tsc --build",
    "build:watch": "tsc --build --watch",
    "build:clean": "tsc --build --clean",
    "build:core": "tsc --build packages/core",
    "build:api": "tsc --build packages/core packages/api",
    "build:cli": "tsc --build packages/core packages/cli"
  },
  "devDependencies": {
    "typescript": "^5.3.0"
  }
}

Compilando e Testando

Com essa estrutura, você pode executar:

# Compila tudo respeitando dependências
npm run build

# Compila apenas core (rápido)
npm run build:core

# Compila core e api
npm run build:api

# Modo watch para desenvolvimento
npm run build:watch

Quando você altera um arquivo em core, o --watch detecta a mudança, recompila core, e então automaticamente recompila api e cli (que dependem dele). Sem Project References, tudo seria recompilado do zero.

Benefícios e Melhores Práticas

Project References com monorepos oferece ganhos reais de performance, especialmente em projetos grandes. A compilação incremental reduz tempos de build de minutos para segundos em iterações normais de desenvolvimento. Além disso, Project References força uma separação clara de responsabilidades: cada projeto tem uma interface bem definida (seus .d.ts), prevenindo acoplamento indevido e dependências circulares.

Uma prática importante é evitar circular references. Se core referencia api e api referencia core, o TypeScript recusará compilar. Use uma arquitetura em camadas onde projetos de nível inferior não dependem de níveis superiores. Também é essencial configurar skipLibCheck: true na raiz para evitar verificação de tipo em node_modules, acelerando ainda mais o build.

Outra consideração é usar --verbose durante o desenvolvimento para entender o que está sendo compilado. Isso ajuda a identificar gargalos e garantir que seus Project References estão configurados corretamente. Se um projeto está sendo recompilado desnecessariamente, a configuração de referências pode estar incorreta.

Conclusão

Dominando Project References em TypeScript, você ganha a capacidade de construir monorepos escaláveis com builds incrementais eficientes. O primeiro aprendizado importante é que Project References não é apenas sintaxe — é uma mudança na forma como você estrutura e pensa sobre dependências entre partes do seu projeto. Segundo, builds incrementais com --build e --watch transformam a experiência de desenvolvimento, reduzindo tempos de espera e aumentando produtividade. Terceiro, uma arquitetura bem planejada com Project References força boas práticas de separação de responsabilidades, tornando o código mais testável e mantível a longo prazo.

Referências


Artigos relacionados