Um monorepo TypeScript que continua rápido depois do segundo ano
Monorepos não ficam lentos por serem grandes. Ficam lentos porque ninguém desenhou o grafo de dependências de propósito.
Todo monorepo que audito no segundo ano tem a mesma reclamação: o type check leva quatro minutos, o editor trava e uma mudança em um pacote reconstrói tudo. O time culpa o tamanho. O tamanho nunca é a causa.
A causa é que o grafo de dependências nunca foi desenhado. Pacotes importam uns aos outros livremente, um pacote compartilhado importa tudo, e o compilador não tem escolha a não ser tratar o repositório inteiro como um único programa.
Project references não são opcionais
TypeScript consegue compilar um monorepo de forma incremental, mas só se você contar a ele o formato. Project references, declaradas no tsconfig de cada pacote, permitem que tsc --build compile os pacotes em ordem de dependência e pule os que não tiveram entradas alteradas. Sem elas, todo type check começa do zero.
O pré-requisito é que cada pacote tenha uma fronteira real: seu próprio tsconfig, suas próprias dependências declaradas e nenhum import que alcance o código-fonte de outro pacote por caminho relativo. No momento em que um arquivo faz ../../other-package/src/thing, a fronteira acabou e a incrementalidade também.
Imponho isso com uma regra de lint que proíbe imports relativos atravessando raízes de pacote, e com isolatedModules e verbatimModuleSyntax ligados, para que cada arquivo possa ser transpilado sozinho e imports só de tipo sejam explícitos. Essas duas flags também mantêm o build compatível com os transpiladores rápidos que não fazem type check nenhum.
O pacote compartilhado que devora o grafo
O padrão mais danoso é o pacote chamado utils, common ou shared. Começa com três helpers. No segundo ano exporta clientes de banco, componentes React, schemas de validação e o motor de preços, e todos os outros pacotes dependem dele. Uma mudança em um helper invalida o repositório inteiro.
A correção é dividir por ritmo de mudança e por público. Tipos que todo mundo precisa vão em um pacote que muda raramente e tem zero dependências de runtime. Componentes de UI vão em um pacote que só apps web importam. Lógica de domínio vai em pacotes com nome do domínio. A regra que aplico: se duas coisas em um pacote têm motivos diferentes para mudar, são dois pacotes.
Vá além e cheque a direção das dependências. Pacotes de domínio não podem importar de pacotes de aplicação. Pacotes de tipos não podem importar de nada. Uma ferramenta que desenha o grafo e falha o build em um ciclo custa uma tarde para configurar e economiza meses.
Cacheando o que o compilador já sabe
Builds incrementais guardam um arquivo .tsbuildinfo com o estado anterior. No CI, esse arquivo precisa ser restaurado do cache ou o build começa frio a cada execução. Já vi times com project references perfeitas e um type check de quinze minutos no CI porque a chave do cache incluía o hash do commit e nunca acertava.
Um task runner com cache remoto leva isso entre máquinas. Se as entradas de um pacote batem com um build anterior de qualquer pessoa do time, as saídas são baixadas em vez de reconstruídas. Essa é a única mudança que faz o segundo ano parecer o primeiro, e depende inteiramente de as fronteiras serem reais, porque a chave do cache são as entradas do pacote.
Type check e bundling são preocupações separadas. O bundler deve transpilar sem checar tipos, e o type check deve rodar como etapa própria, por pacote, em paralelo. Acoplar os dois significa o caminho mais lento possível em cada build.
O problema do editor
Lentidão no editor é um gargalo diferente. O language server carrega um programa por tsconfig que encontra, e se o tsconfig raiz inclui todos os pacotes, ele carrega o mundo inteiro. Mantenha o config raiz mínimo e deixe o config de cada pacote ser a unidade que o editor abre. Configs no estilo solution, com references e sem arquivos próprios, existem exatamente para isso.
A versão nativa do compilador TypeScript muda as constantes aqui, e é uma melhoria real, mas não muda o formato do problema. Um grafo sem fronteiras continua sendo um programa só, e um programa só é sempre a coisa mais lenta que você pode pedir a um compilador para checar.
Desenhe o grafo no primeiro dia. É a hora mais barata do projeto inteiro.