12k
All articles

Boas Práticas de TypeScript para Projetos de Grande Escala

Boas práticas de TypeScript para grandes projetos: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, tipos gerados, validação em runtime e guardrails de CI.

OpenReplay Team
OpenReplay Team
Boas Práticas de TypeScript para Projetos de Grande Escala

Em larga escala, o TypeScript só compensa com consistência disciplinada: o modo strict como linha de base, fronteiras explícitas e validadas, tipos gerados nas bordas para que as equipes não se desviem, e um conjunto reduzido de padrões com os quais toda a equipe concorda de fato. A linguagem deixou de ser a parte difícil há anos — a parte difícil é manter uma base de código com um milhão de linhas e múltiplos colaboradores refatorável sem que uma regressão de segurança de tipos se infiltre em cada PR. Este guia cobre as convenções, flags do compilador e padrões arquiteturais que se sustentam nessa escala, com um caminho de migração para a base de código permissiva que você provavelmente herdou, e está atualizado para as duas versões que redefiniu o cenário de 2026: TypeScript 6.0 (GA) e 7.0 (Release Candidate).

Principais Conclusões

  • A partir do TypeScript 6.0 (lançado em 23 de março de 2026), strict tem como padrão true no nível do compilador, portanto, em uma base de código moderna de grande escala, o modo strict é o ponto de partida, não o ponto de chegada.
  • As duas flags que realmente fazem diferença em uma base de código de grande escala não fazem parte do strict: noUncheckedIndexedAccess e exactOptionalPropertyTypes precisam ser habilitadas explicitamente, e capturam os bugs de índice de array e de propriedade opcional que o strict silenciosamente permite.
  • Tipos gerados são a prática de maior alavancagem em uma base de código multi-equipe: quando o frontend e o backend derivam seus tipos de um único schema OpenAPI ou Prisma, os dois lados fisicamente não podem divergir, e o CI falha no momento em que o contrato muda.
  • Tipos estáticos são uma promessa em tempo de compilação, não uma verificação em tempo de execução — uma resposta tipada como User é apenas afirmada como tal, razão pela qual toda fronteira externa precisa de validação em tempo de execução além de um tipo gerado.
  • A Microsoft relata que o compilador baseado em Go do TypeScript 7.0 é frequentemente ~10× mais rápido que o 6.0 em bases de código de grande escala, e em seu Release Candidate de junho de 2026 já está incorporado ao binário padrão tsc e ao pacote typescript.

Disciplina de compilador: o modo strict é o piso, não a conquista

Todo artigo superficial de boas práticas ainda diz para você “habilitar o modo strict” como se fosse uma opção heroica. Esse enquadramento está agora obsoleto. A partir das notas de lançamento do TypeScript 6.0, strict é true por padrão no nível do compilador — se você dependia do antigo padrão false, agora precisa definir "strict": false explicitamente. O TypeScript 6.0 foi anunciado em 23 de março de 2026 e é considerado o último lançamento baseado na base de código JavaScript atual. Portanto, em qualquer projeto que atualize seu compilador, o strict é a linha de base assumida.

A verdadeira evolução para projetos de grande escala são as duas flags de alto valor que o strict não inclui. O strict ativa aproximadamente nove verificações de segurança de tipos (noImplicitAny, strictNullChecks e outras), mas deixa de fora o noUncheckedIndexedAccess, que adiciona undefined a todo acesso de índice não declarado, e o exactOptionalPropertyTypes, que distingue uma propriedade definida como undefined de uma propriedade ausente. Eles capturam exatamente os bugs que escapam de uma base de código “strict”: a busca em array que assume que um elemento existe, e o campo opcional que está presente mas é undefined.

Um tsconfig.json para projetos de grande escala com versão definida (TypeScript 6.0.x):

{
  "compilerOptions": {
    "strict": true,                      // padrão desde 6.0; mantenha explícito para toolchains mais antigos
    "noUncheckedIndexedAccess": true,    // arr[i] é T | undefined, não T
    "exactOptionalPropertyTypes": true,  // { x?: number } rejeita { x: undefined }
    "verbatimModuleSyntax": true,        // impõe imports somente de tipo (veja performance de build)
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "composite": true                    // obrigatório para referências de projeto
  }
}

O guia de migração incremental para strictness

Se você herdou uma base de código permissiva, não ative todas as flags de uma vez — aplique o strictness diretório por diretório, acompanhe um percentual de type-coverage no CI e eleve o limite progressivamente para que a cobertura nunca regrida. Um aplicativo de 200 mil linhas com milhares de anys implícitos não vai compilar limpo no primeiro dia, e um PR com 2.800 erros é impossível de revisar.

Uma sequência realista:

  1. Ative strict: true globalmente, mas delimite a aplicação: mantenha um tsconfig base permissivo e adicione arquivos tsconfig.json mais restritivos por diretório de funcionalidade usando referências de projeto, começando pelos piores infratores.
  2. Adicione type-coverage ao CI como um ratchet — falhe o build se o percentual de símbolos tipados cair abaixo do último número registrado. A cobertura pode estabilizar, mas nunca regredir.
  3. Aplique as flags extras (noUncheckedIndexedAccess, exactOptionalPropertyTypes) com // @ts-expect-error nas violações restantes, depois vá eliminando a lista. O @ts-expect-error se auto-reporta quando uma supressão se torna desnecessária, então o backlog não pode apodrecer silenciosamente.

Design de tipos em larga escala

Bons tipos em larga escala tornam estados ilegais incompiláveis e tornam erros de domínio evidentes. Três padrões fazem a maior parte do trabalho; o restante é consistência.

A regra de interface vs type, definida de uma vez: use interface para contratos de objeto públicos e extensíveis (suporta declaration merging e tende a produzir mensagens de erro melhores em shapes de objetos grandes), e use type para unions, intersections, tipos mapeados e condicionais. É esse o debate inteiro. Escolha a regra, aplique via lint e siga em frente.

Torne estados impossíveis irrepresentáveis

O acúmulo de flags booleanas é a fonte mais comum de bugs do tipo “isso nunca deveria acontecer” em uma base de código de UI de grande escala. O tipo abaixo permite dezesseis combinações, a maioria sem sentido — isLoading e error definidos ao mesmo tempo, data presente durante um erro:

// Anti-padrão: cada campo independente, estados impossíveis permitidos
interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Uma union discriminada reduz isso exatamente aos estados que podem ocorrer, e o compilador obriga você a tratar cada um deles:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "success":
      return state.data;   // data existe apenas aqui
    case "error":
      return state.error;  // error existe apenas aqui
    // um case ausente é um erro de compilação com uma verificação de exaustividade adequada
  }
}

Replays de sessão de estados de requisição com flags booleanas frequentemente revelam o modo de falha que esse padrão elimina: uma UI que renderiza um spinner e dados desatualizados simultaneamente porque dois booleanos independentes ficaram fora de sincronia.

Aplique branding nos seus IDs de domínio

Tipos com branding tornam UserId e OrderId tipos incompatíveis mesmo que ambos sejam string em tempo de execução, tornando um erro de compilação passar um onde o outro é esperado:

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const asUserId = (s: string) => s as UserId;

function cancelOrder(id: OrderId) { /* ... */ }

const u = asUserId("u_123");
cancelOrder(u); // ❌ Argument of type 'UserId' is not assignable to 'OrderId'

Em uma assinatura de função com cinco argumentos do tipo string, o branding é a diferença entre um bug de argumento transposto encontrado em tempo de compilação e um encontrado em produção.

Prefira unknown em vez de any. O any desabilita o verificador e se propaga silenciosamente; o unknown exige uma etapa de narrowing antes do uso. Proíba any no lint e trate todo valor externo — JSON.parse, bindings de catch, retornos de bibliotecas sem tipos — como unknown até prova em contrário. Use as const em configurações literais e tabelas de lookup para que sejam inferidos como tipos literais estreitos em vez de primitivos alargados.

Modele e gere tipos nas suas fronteiras

A decisão arquitetural de maior alavancagem em uma base de código de grande escala é como você tipa as bordas. Duas regras.

Primeiro, não reutilize um único tipo User entre o wire, o banco de dados e a UI — modele a resposta da API, o DTO e a entidade de domínio como três tipos distintos para que uma mudança em uma fronteira não possa corromper silenciosamente outra. O shape que seu backend serializa, o shape que seu ORM retorna e o shape que seus componentes consomem divergem ao longo do tempo; colapsá-los em um único tipo acopla cada camada a todas as outras.

Segundo, gere os tipos de fronteira em vez de escrevê-los manualmente. Quando o frontend e o backend derivam seus tipos de um único schema, os dois lados fisicamente não podem divergir, e o CI falha no momento em que o contrato muda. Use openapi-typescript para converter um documento OpenAPI 3.0/3.1 em tipos sem runtime, o Prisma para tipos derivados do banco de dados, ou o GraphQL Code Generator para operações tipadas. Regenere no CI e falhe em caso de diff:

# Etapa de CI: regenerar e falhar se os tipos commitados estiverem desatualizados
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts

Mas um tipo gerado ainda é apenas uma promessa em tempo de compilação. Tipos estáticos são uma promessa em tempo de compilação, não uma verificação em tempo de execução — uma resposta tipada como User é apenas afirmada como tal, razão pela qual toda fronteira externa precisa de validação em tempo de execução além de um tipo gerado. Valide o payload real com uma biblioteca de schema como Zod ou Valibot e derive o tipo estático do schema, para que uma única definição proteja ambas as camadas:

import { z } from "zod";

const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;

const res = await fetch("/api/me");
const user = User.parse(await res.json()); // lança exceção se o shape real for diferente

O argumento para validar fronteiras, não apenas tipá-las, é empírico: tipos estáticos desaparecem em tempo de execução, e o replay de sessão é uma técnica para ver as falhas que os tipos não conseguem capturar — o momento em que uma resposta real da API não corresponde ao seu shape declarado e a UI quebra na sessão de um usuário.

Organize tipos para uma base de código com múltiplos colaboradores

  • Coloque os tipos junto ao código que os utiliza — mesmo arquivo, ou um *.types.ts adjacente — e reserve um types/index.ts (ou um pacote dedicado) apenas para contratos genuinamente compartilhados.
  • Organize por pasta de funcionalidade/domínio, não por camada técnica, para que os tipos, componentes e lógica de uma funcionalidade fiquem juntos e a propriedade seja óbvia.
  • Em um monorepo, conecte os pacotes com referências de projeto e mapeamento de paths para que os imports cruzem fronteiras de módulo limpas (@org/billing) em vez de caminhos frágeis com ../../../, e para que o compilador imponha o grafo de dependências.

Performance de build em 2026: o compilador nativo muda o cálculo

A conversa sobre performance de build mudou fundamentalmente, e não se trata mais de economizar segundos com flags do tsc. O TypeScript anunciou o Release Candidate do 7.0 em 18 de junho de 2026; com velocidade de código nativo e paralelismo com memória compartilhada, ele é frequentemente cerca de 10 vezes mais rápido que o TypeScript 6.0. O benchmark principal da Microsoft verificou a base de código do VS Code, com ~1,5 milhão de linhas, em aproximadamente 7,5 segundos versus 77,8 no compilador anterior, embora o múltiplo seja menor em projetos pequenos.

O empacotamento é a parte a se atentar. A principal mudança prática no RC é o empacotamento: a reescrita baseada em Go saiu de um pacote native-preview separado e entrou no pacote npm regular do TypeScript, portanto o TypeScript 7.0 agora está pronto para testes mais amplos como o compilador tsc normal. Instale com npm install -D typescript@rc e execute o binário padrão tsc — os pacotes mais antigos tsgo / @typescript/native-preview agora carregam apenas nightlies. Em fins de junho de 2026, a versão estável mais recente é o TypeScript 6.0.3, com o 7.0 no RC e o GA estável esperado aproximadamente um mês após o RC. Trate a versão/estágio como volátil e verifique novamente no momento da adoção.

Os controles estruturais que você gerencia na sua própria configuração ainda valem a pena em qualquer compilador: referências de projeto para builds incrementais e cientes de dependências, e imports somente de tipo impostos pelo verbatimModuleSyntax para que símbolos somente de tipo sejam apagados e nunca emitidos como imports em tempo de execução. O verbatimModuleSyntax (introduzido no 5.0) é a abordagem recomendada atualmente; as flags que ele substituiu, importsNotUsedAsValues e preserveValueImports, foram tornadas no-ops no 5.5 e causam erro ao serem especificadas a partir do 6.0.

import type { User } from "./user";  // apagado completamente da saída JS
import { fetchUser } from "./api";   // import de valor, preservado

Automatize as proteções

Convenções que não são impostas se deterioram. Execute o typescript-eslint com regras cientes de tipos (no-explicit-any, no-floating-promises, no-misused-promises) para que os padrões acima sejam verificados mecanicamente, não em revisão. Deixe a imposição de imports somente de tipo para o verbatimModuleSyntax em vez da regra de lint consistent-type-imports — executar ambos é redundante e pode produzir erros conflitantes. Execute tsc --noEmit no CI em cada PR como uma barreira rígida, junto com o ratchet de type-coverage do guia de migração. E mantenha uma proteção humana acima de toda a automação: clareza acima de esperteza. Um tipo condicional e mapeado profundamente aninhado que leva dez minutos para um engenheiro sênior ler é um passivo, não uma demonstração de habilidade — a maior parte do código de tipos em projetos de grande escala deve ser entediante, legível e óbvio.

O fio condutor é consistência, não sofisticação. Ative as duas flags que o strict omite, torne seus estados ilegais incompiláveis, gere e valide suas fronteiras, e deixe o CI impor o restante. Adote o tsconfig com versão fixada acima como sua linha de base esta semana, depois aponte o ratchet de type-coverage para o seu diretório mais problemático e comece a subir.

Perguntas Frequentes

O modo strict é suficiente para um projeto TypeScript de grande escala?

Não. A partir do TypeScript 6.0, o strict já tem como padrão true no nível do compilador, portanto é a linha de base, não uma conquista. As duas flags que mais importam em uma base de código de grande escala não fazem parte da família strict: noUncheckedIndexedAccess, que adiciona undefined ao acesso de índice não declarado, e exactOptionalPropertyTypes, que distingue uma propriedade definida como undefined de uma propriedade ausente. Habilite ambas explicitamente, depois adicione tipos de fronteira e validação em tempo de execução.

Qual é a diferença entre interface e type no TypeScript, e quando devo usar cada um?

Use interface para contratos de objeto públicos e extensíveis porque suporta declaration merging e tende a produzir mensagens de erro mais claras em shapes de objetos grandes. Use type para unions, intersections, tipos mapeados e condicionais, que interface não consegue expressar. Para uma equipe grande, a regra prática é escolher essa convenção uma vez, impô-la com uma regra de lint e parar de debatê-la. Ambos compilam para verificações de tipo idênticas em shapes de objetos simples, portanto a escolha é sobre expressividade e consistência, não sobre capacidade.

Tipos gerados a partir de OpenAPI ou Prisma tornam a validação em tempo de execução desnecessária?

Não. Um tipo gerado é apenas uma promessa em tempo de compilação. Uma resposta JSON tipada como User é meramente afirmada como correspondente a esse shape; o compilador nunca inspeciona o payload real em tempo de execução, portanto uma mudança no backend ou um campo nulo ainda passa despercebido. Tipos gerados impedem que o frontend e o backend divirjam no contrato, mas você ainda precisa de uma biblioteca de schema como Zod ou Valibot para validar o payload real em cada fronteira externa. Derive o tipo estático do schema para que uma única definição proteja ambas as camadas.

Como instalo e executo o TypeScript 7.0 no estágio de Release Candidate?

Instale com npm install -D typescript@rc e execute o binário padrão tsc. No Release Candidate de junho de 2026, o compilador nativo baseado em Go saiu do pacote native-preview separado e entrou no pacote npm regular do typescript, portanto não há mais um binário tsgo distinto para o RC; os pacotes mais antigos tsgo e typescript native-preview agora carregam apenas nightlies. A Microsoft relata que o 7.0 é frequentemente cerca de dez vezes mais rápido que o 6.0 em bases de código de grande escala. Trate a versão e o estágio como voláteis e verifique novamente antes de adotar.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.