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.
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),
stricttem como padrãotrueno 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:noUncheckedIndexedAccesseexactOptionalPropertyTypesprecisam 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
tsce ao pacotetypescript.
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
Discover how at OpenReplay.com.
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:
- Ative
strict: trueglobalmente, mas delimite a aplicação: mantenha umtsconfigbase permissivo e adicione arquivostsconfig.jsonmais restritivos por diretório de funcionalidade usando referências de projeto, começando pelos piores infratores. - Adicione
type-coverageao 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. - Aplique as flags extras (
noUncheckedIndexedAccess,exactOptionalPropertyTypes) com// @ts-expect-errornas violações restantes, depois vá eliminando a lista. O@ts-expect-errorse 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.tsadjacente — e reserve umtypes/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
pathspara 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.
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