TypeScript por dentro: como os tipos pegam erros antes de o código rodar
Uma imersão no TypeScript, da tipagem estrutural ao apagamento de tipos, com narrowing, genéricos, modo estrito, validação com Zod e o jeito como usamos a linguagem na Gothor.

Um campo renomeado na API, e três telas quebram em produção. Um undefined que ninguém esperava, e um relatório inteiro sai em branco. Uma função chamada com os argumentos na ordem errada, e o desconto vira acréscimo. Em JavaScript puro, esses erros só aparecem quando o código roda, muitas vezes na mão do usuário. O TypeScript existe para trazê-los para antes: para o editor, enquanto você digita, e para a integração contínua, antes de o código chegar à produção.
Hoje o TypeScript é o padrão de fato em projetos JavaScript de médio e grande porte, do navegador ao servidor. Mas muita gente o usa como um "JavaScript com anotações" e se frustra: tipos que mentem, any espalhado, erros incompreensíveis. Este artigo mostra como a linguagem funciona por dentro, por que os tipos desaparecem na compilação, como o compilador raciocina sobre o seu código e quais práticas fazem os tipos trabalharem a seu favor.
O percurso vai da ideia central (tipos que existem só até a compilação) aos detalhes que fazem diferença no dia a dia: como o compilador decide se um valor serve, como ele lê os seus if, como genéricos e tipos utilitários evitam repetição, como modelar estados de um sistema de forma que os erros fiquem impossíveis de escrever, como configurar o projeto e migrar um código antigo, onde o sistema de tipos é deliberadamente frouxo e por que dados vindos de fora sempre precisam de validação em execução. No fim, mostramos como a Gothor usa a linguagem nos seus projetos.
O que é TypeScript
TypeScript é um superconjunto tipado do JavaScript: todo JavaScript válido é TypeScript válido, e o TypeScript acrescenta uma camada de tipos estáticos opcionais. Você descreve o formato dos dados e das funções, e o compilador verifica, sem executar nada, se o código é coerente com essa descrição.
interface Pedido {
id: number;
cliente: string;
total: number;
situacao: 'aberto' | 'faturado' | 'cancelado';
}
function aplicarDesconto(pedido: Pedido, percentual: number): Pedido {
return { ...pedido, total: pedido.total * (1 - percentual / 100) };
}
aplicarDesconto(10, pedido);
// erro: o argumento do tipo 'number' não é atribuível ao parâmetro do tipo 'Pedido'
Três características definem a linguagem:
- Os tipos são apagados. O compilador verifica e depois remove as anotações, gerando JavaScript comum. Em execução, não existe nenhum tipo.
- A tipagem é estrutural. Dois tipos são compatíveis se têm o mesmo formato, e não porque têm o mesmo nome.
- O sistema é gradual. Dá para tipar um projeto aos poucos, e o rigor é ajustável por configuração.
O compilador é só metade do produto. A outra metade é o serviço de linguagem, que alimenta o editor: autocompletar preciso, ir para a definição, renomear um símbolo no projeto inteiro e ver o erro sublinhado antes de salvar o arquivo. Para muita gente, é aí que o TypeScript se paga primeiro.
O que o TypeScript não é
Algumas expectativas erradas atrapalham quem está começando, e vale desfazê-las logo:
- Não é uma linguagem nova com execução própria. Não existe "máquina virtual do TypeScript". O que roda é sempre JavaScript, no navegador, no Node.js, no Deno ou no Bun.
- Não deixa o código mais rápido nem mais lento. Como os tipos somem, o JavaScript gerado é praticamente o mesmo que você escreveria à mão. O TypeScript não usa os tipos para otimizar nada.
- Não valida dados em execução. Um
Pedidodeclarado no código não impede que a API devolva outra coisa. Essa conferência é trabalho de outra ferramenta, como veremos na seção sobre a fronteira. - Não substitui testes. Os tipos garantem que as peças se encaixam; os testes garantem que o resultado está certo. Um cálculo de juros pode estar perfeitamente tipado e errado.
- Não é uma prova matemática. O sistema de tipos foi desenhado para ser útil, e não para ser à prova de tudo. Há brechas conhecidas e deliberadas, que este artigo também mostra.
Uma história curta
No começo dos anos 2010, a web já rodava aplicações enormes, como clientes de e-mail e editores de documentos, escritas numa linguagem pensada para pequenos scripts de página. O JavaScript da época (o ECMAScript 5) não tinha módulos, nem classes, nem qualquer forma de declarar o formato de um objeto. Refatorar uma base de código grande era arriscado, porque nenhuma ferramenta conseguia dizer com certeza onde um nome era usado.
Várias empresas tentaram resolver isso de jeitos diferentes. O Google tinha o Closure Compiler, que lia tipos escritos em comentários, e lançou o Dart em 2011, uma linguagem nova que compilava para JavaScript. O CoffeeScript, de 2009, oferecia outra sintaxe, mais enxuta, sem tipos. Mais tarde, em 2014, o Facebook apresentou o Flow, um verificador de tipos para JavaScript com uma proposta parecida com a do TypeScript.
Na Microsoft, Anders Hejlsberg, criador do Turbo Pascal e arquiteto do C#, liderou um projeto com uma restrição clara: não criar uma linguagem incompatível com a web. O resultado seria um JavaScript com tipos que compilasse para JavaScript comum, seguindo de perto as propostas do comitê que padroniza a linguagem, o TC39. A primeira versão pública saiu em outubro de 2012, com código aberto, e a 1.0 em 2014.
A adoção ganhou força por três caminhos. O Visual Studio Code, lançado em 2015, foi escrito em TypeScript e usa o serviço de linguagem dele até para arquivos JavaScript, o que mostrou a ferramenta a milhões de pessoas. O Angular 2, do Google, foi escrito em TypeScript, em 2016. E o repositório comunitário DefinitelyTyped reuniu os tipos de milhares de bibliotecas JavaScript, de forma que quem adotava a linguagem não precisava esperar os autores dessas bibliotecas.
Desde então, o sistema de tipos evoluiu rápido, sempre com o mesmo objetivo: descrever com precisão o JavaScript que as pessoas já escreviam.
- 2.0 (2016): verificação estrita de
nulleundefined, o tiponevere as uniões discriminadas. - 2.1 (2016):
keyofe os tipos mapeados, base dos tipos utilitários. - 2.3 (2017): a opção
--strict, que liga de uma vez o conjunto de verificações rigorosas. - 2.8 (2018): os tipos condicionais, com
infer. - 3.0 (2018): o tipo
unknown, a alternativa segura aoany. - 3.7 (2019): encadeamento opcional (
?.), coalescência nula (??) e funções de asserção. - 4.1 (2020): tipos de template literal, que operam sobre textos, e
noUncheckedIndexedAccess. - 4.9 (2022): o operador
satisfies. - 5.0 (2023): os decoradores do padrão ECMAScript, os parâmetros de tipo
conste a resolução de módulosbundler. - 5.5 (2024): predicados de tipo inferidos, que fazem
lista.filter(x => x !== undefined)devolver uma lista semundefined. - 5.8 (2025): a opção
erasableSyntaxOnly, para código que pode ser executado só removendo os tipos. - 6.0 (março de 2026): a última versão do compilador escrito em TypeScript, com
strictligado por padrão e opções antigas descontinuadas. - 7.0 (julho de 2026): o compilador reescrito em Go, cerca de dez vezes mais rápido.
Em março de 2025, Anders Hejlsberg anunciou que o compilador, escrito em TypeScript desde o início, seria portado para Go. O TypeScript 6.0, de março de 2026, foi a versão de transição, que descontinuou opções antigas e preparou os projetos para a mudança. O TypeScript 7.0 saiu em julho de 2026 com o compilador nativo. A seção "O TypeScript 7 e o compilador nativo", mais adiante, conta o que mudou.
Um detalhe dessa história ajuda a entender a linguagem. Entre os objetivos de projeto publicados pela equipe, há um "não objetivo" explícito: aplicar um sistema de tipos que seja provadamente correto. A meta declarada é equilibrar correção e produtividade. Por isso o TypeScript aceita algumas construções que um sistema de tipos acadêmico recusaria, e por isso entender onde ficam essas brechas faz parte de usar bem a ferramenta.
Tipos dentro do próprio JavaScript?
Uma pergunta aparece sempre que se fala em apagar tipos: por que o próprio JavaScript não aceita anotações e as ignora, como faz com os comentários? Existe uma proposta no TC39 exatamente com essa ideia, a Type Annotations, conduzida por Daniel Rosenwasser (Microsoft), Romulo Cintra (Igalia) e Rob Palmer (Bloomberg). Ela propõe que os motores aceitem uma sintaxe de tipos e a tratem como comentário: nada é verificado em execução, e a verificação fica com ferramentas externas, como o TypeScript ou o Flow.
A proposta está no estágio 1 do processo do TC39, o de explorar o problema e o desenho da solução, e o próprio repositório avisa que não é atualizado com frequência. Há boas razões para a demora: definir que trecho de código conta como "tipo" sem fixar a gramática de uma ferramenta específica é difícil, e qualquer sintaxe nova no JavaScript precisa conviver com o código que já existe na web.
Enquanto isso, o ecossistema chegou a um resultado prático parecido por outro caminho. O Node.js, o Deno e o Bun executam arquivos .ts removendo os tipos na hora, e a opção erasableSyntaxOnly garante que o código use só construções que podem ser apagadas. Na prática, quem escreve TypeScript "apagável" hoje está escrevendo o tipo de código que essa proposta tornaria JavaScript puro.
Como funciona: tipos que somem na compilação
Para usar bem o TypeScript, é preciso entender o que o compilador faz e, principalmente, o que ele não faz.

O tsc, o compilador oficial, trabalha em etapas. O analisador (parser) transforma o texto numa árvore sintática. O vinculador (binder) liga cada nome à sua declaração. O verificador (checker), a maior parte do compilador, calcula o tipo de cada expressão e compara com o que é esperado. Por fim, o emissor gera o JavaScript e, se pedido, os arquivos de declaração .d.ts, que descrevem os tipos de uma biblioteca já compilada.
O ponto decisivo: o emissor não depende do verificador. Mesmo com erros de tipo, o tsc gera JavaScript (a menos que você peça noEmitOnError), porque gerar JavaScript é, na maior parte, só apagar as anotações:
// o que você escreve
function total(itens: Item[]): number {
return itens.reduce((s, i) => s + i.preco, 0);
}
// o que o compilador gera: as mesmas linhas, sem as anotações
function total(itens) {
return itens.reduce((s, i) => s + i.preco, 0);
}
Isso abriu espaço para ferramentas muito mais rápidas, como o esbuild, o SWC e o Vite, que só apagam os tipos, arquivo por arquivo, sem verificar nada. Por isso a maioria dos projetos atuais divide o trabalho em dois: uma ferramenta rápida gera o JavaScript e o tsc --noEmit confere os tipos, no editor e na integração contínua. A opção isolatedModules garante que cada arquivo possa ser transformado sozinho, como essas ferramentas exigem.
O Node.js atual também executa arquivos .ts diretamente, removendo os tipos na hora. A regra é a mesma: executar não é verificar. Um arquivo cheio de erros de tipo roda normalmente se a sintaxe estiver certa.
TypeScript direto no Node.js
A remoção de tipos no Node.js chegou aos poucos. Ela apareceu na versão 22.6, atrás de uma opção experimental; passou a vir ligada por padrão na 23.6 e na 22.18; deixou de emitir o aviso de recurso experimental na 24.3; e foi declarada estável na 24.12 e na 25.2. A técnica é simples e engenhosa: o Node troca cada anotação de tipo por espaços em branco. Como as posições das linhas e colunas não mudam, as mensagens de erro e o depurador apontam para o lugar certo sem precisar de mapas de código-fonte.
Essa simplicidade traz regras que pegam desprevenido quem vem de um projeto compilado:
- **O
tsconfig.jsoné ignorado.** Nada depathspara apelidos de importação nem de conversão para versões antigas da linguagem. O substituto dos apelidos são as importações de subcaminho dopackage.json, que começam com#. - A extensão é obrigatória nas importações, e é a extensão real do arquivo:
import './pedido.ts'. Com a opçãorewriteRelativeImportExtensions, do TypeScript 5.7, otscreescreve esses caminhos para.jsquando gera uma versão compilada. - **Importações só de tipo precisam de
import type.** Sem a palavratype, o Node trata a importação como um valor e falha em execução, porque o tipo não existe no arquivo de destino. A opçãoverbatimModuleSyntaxfaz o compilador exigir isso. - Só sintaxe apagável.
enum,namespacecom valores e propriedades declaradas no construtor geram o erroERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Decoradores também não são aceitos. - **Nada dentro de
node_modules.** O Node se recusa a remover tipos de arquivos de dependências, o que obriga os pacotes publicados a trazer JavaScript compilado.
A documentação do Node recomenda um tsconfig.json que espelha essas regras, para que o editor e a integração contínua avisem antes de o Node reclamar:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}
Para scripts de manutenção, ferramentas internas e pequenos serviços, isso dispensa qualquer etapa de build. Para aplicações maiores, a divisão continua a mesma: o Node executa, e o tsc confere os tipos.
Nem toda sintaxe é apagável
Quase tudo no TypeScript é anotação que some. Algumas construções antigas, porém, geram código: os enum, os namespace com valores e as propriedades declaradas no construtor (constructor(private repo: Repo)). Elas não podem ser removidas por uma simples limpeza de tipos. A opção erasableSyntaxOnly proíbe essas construções e garante que o código rode em qualquer ferramenta que só apaga tipos. No lugar de um enum, use uma união de literais ou um objeto com as const:
const SITUACOES = ['aberto', 'faturado', 'cancelado'] as const;
type Situacao = (typeof SITUACOES)[number]; // 'aberto' | 'faturado' | 'cancelado'
A lista continua existindo em execução (dá para percorrê-la num select da tela, por exemplo), e o tipo sai dela. É a mesma informação escrita uma vez só.
Por que os enums perderam espaço
O enum foi um dos primeiros recursos do TypeScript e ainda aparece em muito código. Vale entender o que ele gera para decidir com consciência. Um enum numérico vira um objeto com mapeamento nos dois sentidos, do nome para o valor e do valor para o nome:
enum Situacao { Aberto = 1, Faturado, Cancelado } // 1, 2 e 3, por incremento
Situacao.Faturado; // 2
Situacao[2]; // 'Faturado': o mapeamento reverso
Esse objeto existe em execução, e é por isso que o enum não pode ser simplesmente apagado. Os enums de texto (Aberto = 'aberto') não ganham o mapeamento reverso, e os enums numéricos têm outro problema: o valor gravado no banco ou enviado pela API é um número, que não diz nada a quem lê um log.
O const enum tenta resolver o custo em execução: o compilador substitui cada uso pelo valor e apaga a declaração. A documentação oficial lista as armadilhas dessa variante. Um const enum publicado nos .d.ts de uma biblioteca não funciona para quem usa isolatedModules, porque uma ferramenta que transforma um arquivo por vez não enxerga os valores declarados em outro. E há o risco de versões desencontradas: o valor é copiado da versão da dependência que existia na compilação, enquanto o código executado pode carregar outra versão, com outros valores. O próprio projeto do TypeScript publica os seus com a opção preserveConstEnums.
A recomendação da documentação é direta: no TypeScript moderno, um objeto com as const muitas vezes basta, e tem a vantagem de manter o código alinhado com o JavaScript.
const Situacao = { Aberto: 'aberto', Faturado: 'faturado', Cancelado: 'cancelado' } as const;
type Situacao = (typeof Situacao)[keyof typeof Situacao]; // 'aberto' | 'faturado' | 'cancelado'
Situacao.Faturado; // uso parecido com o de um enum, e apagável
Os arquivos de declaração e a biblioteca padrão
Como o compilador sabe que Array tem o método map, ou que document.querySelector existe no navegador? Pelos arquivos de declaração (.d.ts): arquivos só com tipos, sem nenhuma implementação. O TypeScript traz uma coleção deles, a biblioteca padrão, dividida por versão do JavaScript e por ambiente: lib.es2022.d.ts, lib.dom.d.ts e assim por diante.
Duas opções do tsconfig.json controlam isso e costumam ser confundidas:
- **
target** diz para qual versão de JavaScript o código é gerado. Com umtargetantigo, recursos novos de sintaxe são reescritos numa forma equivalente mais antiga. - **
lib** diz quais APIs o compilador considera disponíveis. Um projeto de servidor não deveria terDOMna lista; se tiver, o compilador aceitawindow.localStoragenum código que vai rodar no Node.js, onde isso não existe.
O mesmo mecanismo vale para as bibliotecas que você instala. Um pacote escrito em TypeScript publica os seus .d.ts junto com o JavaScript compilado. Um pacote escrito em JavaScript depende de alguém escrever esses arquivos à mão, no próprio pacote ou no DefinitelyTyped. E quando nenhum dos dois existe, você pode declarar o mínimo necessário num arquivo seu, com declare module 'nome-do-pacote'.
A opção skipLibCheck, ligada em quase todos os projetos, faz o compilador não verificar os .d.ts das dependências. Ela economiza tempo e evita erros em arquivos que você não controla; os seus próprios arquivos continuam verificados normalmente.
O serviço de linguagem e a compilação incremental
O editor não roda o tsc do zero a cada tecla. Ele conversa com o tsserver, um processo que mantém o projeto carregado na memória e recalcula só o que mudou. Quando você passa o mouse sobre uma variável e vê o tipo dela, ou pede para renomear uma função em todos os arquivos, é o tsserver respondendo, com o mesmo verificador do compilador. No TypeScript 7, esse papel passou para um novo servidor, construído sobre o Language Server Protocol (LSP), o protocolo padrão entre editores e ferramentas de linguagem, e capaz de usar várias threads.
Na linha de comando, a opção incremental faz o tsc gravar um arquivo .tsbuildinfo com o resultado da última verificação. Na próxima execução, ele compara as assinaturas dos arquivos e reverifica só o necessário. Em repositórios com vários projetos, as referências de projeto (references, com composite ligado nos projetos referenciados) dividem o trabalho em partes que podem ser verificadas separadamente e na ordem certa. É comum ver isso em projetos criados pelo Vite, que separam a configuração do código da aplicação da configuração dos arquivos que rodam no Node.js, como o próprio vite.config.ts.
Onde o sistema de tipos é frouxo de propósito
Lembra do "não objetivo" de ser provadamente correto? Ele aparece em pontos concretos, que todo mundo que usa TypeScript encontra cedo ou tarde:
// 1. Arrays são covariantes: uma lista de cachorros "é" uma lista de animais...
const cachorros: Cachorro[] = [rex];
const animais: Animal[] = cachorros;
animais.push(gato); // aceito: agora há um gato na lista de cachorros
// 2. Métodos aceitam parâmetros mais específicos (bivariância de métodos)
interface Validador { validar(x: string | number): boolean }
const so: Validador = { validar(x: string) { return x.length > 0; } }; // aceito
so.validar(42); // quebra em execução: 42 não tem length
// 3. O narrowing de uma propriedade sobrevive a chamadas de função
if (pedido.cliente !== null) {
limpar(pedido); // pode ter posto null em pedido.cliente
pedido.cliente.nome; // o compilador ainda acha que não é null
}
Cada uma dessas brechas existe porque fechá-la tornaria o código idiomático de JavaScript muito chato de escrever. A opção strictFunctionTypes fecha parte do segundo caso para propriedades declaradas como funções (validar: (x: string | number) => boolean), mas não para a sintaxe de método. Conhecer essas brechas não significa evitá-las a todo custo; significa saber onde o compilador está confiando em você, e escrever código que não abuse dessa confiança, como evitar mutar listas recebidas de fora ou funções que mudam objetos às escondidas.
Variância: quando um tipo genérico cabe no lugar de outro
As duas primeiras brechas são casos de variância, a regra que decide se Caixa<Cachorro> pode ser usado onde se espera Caixa<Animal>. A resposta depende de como o parâmetro de tipo é usado. Se T só sai (uma função que devolve T), a substituição é segura, e o tipo é covariante. Se T só entra (uma função que recebe T), a substituição segura é a inversa, e o tipo é contravariante. Se T entra e sai, nenhuma substituição é segura, e o tipo é invariante.
O compilador normalmente calcula a variância sozinho, como atalho para não comparar estruturas inteiras. Desde o 4.7, dá para declará-la com out, in ou os dois:
type Leitor<out T> = () => T; // covariante: só devolve T
type Gravador<in T> = (valor: T) => void; // contravariante: só recebe T
interface Estado<in out T> { // invariante: recebe e devolve
ler: () => T;
gravar: (valor: T) => void;
}
O compilador confere se a anotação bate com o uso e acusa erro quando não bate. As notas de versão recomendam não anotar todo parâmetro: o uso indicado é em tipos profundamente recursivos, sobretudo em bibliotecas, onde o cálculo automático pode ser caro ou impreciso e a anotação melhora tanto a correção quanto a velocidade da verificação.
Tipagem estrutural: o formato decide
Em linguagens como Java e C#, a tipagem é nominal: um valor é de um tipo porque foi declarado assim. No TypeScript, ela é estrutural: um valor é aceito onde se espera um tipo se tiver, pelo menos, as propriedades exigidas, com os tipos certos. O nome não importa.

interface ComNome { nome: string }
class Cliente { constructor(public nome: string, public documento: string) {} }
const fornecedor = { nome: 'Distribuidora', prazo: 30 };
function saudar(x: ComNome) { return `Olá, ${x.nome}`; }
saudar(new Cliente('Ana', '000')); // aceito: tem nome
saudar(fornecedor); // aceito: tem nome (prazo é ignorado)
saudar({ nome: 'Ana', idade: 30 }); // erro: objeto literal com propriedade a mais
O último caso mostra uma exceção deliberada, a verificação de propriedades excedentes: quando você escreve um objeto literal direto no lugar do tipo, uma propriedade desconhecida provavelmente é erro de digitação, e o compilador avisa. Passado por uma variável, o mesmo objeto é aceito.
A tipagem estrutural combina com o JavaScript, em que objetos são criados soltos, sem classe. O efeito colateral é que tipos com o mesmo formato são intercambiáveis: um idCliente: number e um idPedido: number se misturam sem aviso. Quando isso importa, a técnica é a marca (branded type), uma propriedade fictícia que só existe no tipo:
type IdCliente = number & { readonly __marca: 'IdCliente' };
type IdPedido = number & { readonly __marca: 'IdPedido' };
function buscarCliente(id: IdCliente) { /* ... */ }
const idPedido = 7 as IdPedido;
buscarCliente(idPedido); // erro: IdPedido não é IdCliente
Na prática, o as da marca fica concentrado num só lugar, como a função que lê o registro do banco ou o esquema que valida a entrada, e o resto do código recebe valores já marcados.
Funções também são comparadas pelo formato
A regra estrutural vale para funções, com um detalhe que surpreende no começo: uma função com menos parâmetros cabe onde se espera uma com mais. É por isso que você pode passar x => x * 2 para o map, que na verdade chama o callback com três argumentos (o item, a posição e a lista):
type AoMudar = (valor: string, anterior: string) => void;
const registrar: AoMudar = (valor) => console.log(valor); // aceito: ignorar parâmetros é seguro
const errado: AoMudar = (valor, anterior, extra) => {}; // erro: exige um terceiro argumento
Ignorar um argumento nunca quebra nada; exigir um que não vai chegar, sim. O retorno segue a lógica inversa: uma função que devolve mais propriedades do que o esperado é aceita, e uma função declarada como () => void pode até devolver um valor, que simplesmente será ignorado por quem chama.
Os tipos do dia a dia
O vocabulário básico cobre a maior parte do código:
- Primitivos:
string,number,boolean,bigint,null,undefined. - Arrays e tuplas:
string[]é uma lista de textos;[number, string]é um par com posições fixas. - Objetos: descritos com
interfaceoutype, com propriedades opcionais (frete?: number) e somente leitura (readonly id: number). - Literais:
'aberto',42outruecomo tipo, que aceita só aquele valor. - Uniões:
string | numberaceita um ou outro. É a ferramenta mais importante para modelar dados reais. - Interseções:
A & Bexige as propriedades dos dois.
interface e type se sobrepõem bastante. A diferença prática: interface pode ser estendida e reaberta (útil para descrever objetos e bibliotecas), enquanto type dá nome a qualquer tipo, inclusive uniões, tuplas e tipos calculados. Muitas equipes usam interface para formatos de objetos e type para o resto.
Três tipos especiais merecem atenção:
| Tipo | O que significa | Quando usar |
|---|---|---|
any | desliga a verificação: tudo é aceito, e tudo pode ser feito com ele | quase nunca; é uma porta aberta |
unknown | pode ser qualquer coisa, mas nada pode ser feito antes de verificar o que é | dados de fora: JSON, catch, entradas |
never | nenhum valor possível | ramos impossíveis e verificação de exaustividade |
A diferença entre any e unknown é a mais importante do dia a dia. Com any, dado.cliente.nome.toUpperCase() compila e quebra em execução. Com unknown, o compilador obriga você a provar o formato antes de usar, e é exatamente isso que um dado vindo de fora exige.
Dois tipos parecem úteis e enganam: Object e {}. Ambos aceitam quase qualquer valor que não seja null ou undefined, inclusive números e textos, e por isso dizem muito pouco. Para "um objeto qualquer com chaves de texto", use Record<string, unknown>; para "qualquer coisa", use unknown.
Inferência: quando não anotar
O TypeScript infere o tipo da maior parte das expressões sozinho. Anotar tudo deixa o código mais longo sem deixá-lo mais seguro:
const taxa = 0.02; // number
const itens = [{ sku: 'A1', qtd: 2 }]; // { sku: string; qtd: number }[]
const total = itens.reduce((s, i) => s + i.qtd, 0); // number
itens.forEach(item => item.qtd); // item é inferido pelo contexto do forEach
Algumas regras de inferência valem a pena conhecer:
- Alargamento de literais.
const situacao = 'aberto'tem o tipo'aberto', porque uma constante nunca muda. Jálet situacao = 'aberto'tem o tipostring, porque a variável pode receber outro texto depois. O mesmo vale para propriedades de objetos: em{ situacao: 'aberto' }, a propriedade éstring, a menos que você useas constou anote o tipo. - Tipagem pelo contexto. Uma função anônima passada como argumento recebe os tipos dos parâmetros a partir de quem a recebe. Por isso o
itemdoforEachnão precisa de anotação. - O melhor tipo comum. Em
[1, 'a'], o compilador infere(string | number)[], e não uma tupla. Para uma tupla, anote ou useas const.
A recomendação geral é anotar as fronteiras: parâmetros de funções, retornos de funções exportadas e propriedades de interfaces públicas. O retorno explícito é especialmente útil porque transforma um erro dentro da função num erro no lugar certo, e não numa mensagem confusa em quem chamou.
readonly e imutabilidade
readonly numa propriedade e ReadonlyArray<T> (ou readonly T[]) numa lista impedem alterações pelo código que recebe o valor. Como todo o resto, é uma garantia só de compilação: em execução, o objeto continua mutável, a menos que você use Object.freeze. Mesmo assim, marcar como somente leitura os dados que uma função recebe deixa claro que ela não vai alterá-los, e fecha a brecha da covariância de arrays mostrada antes, porque uma lista somente leitura não tem push.
Narrowing: o compilador lê os seus ifs
Uma variável do tipo string | number não pode ser tratada como texto sem antes descobrir o que ela é. O TypeScript acompanha o fluxo do código e estreita (narrowing) o tipo a cada verificação: dentro de um if (typeof x === 'string'), x é string; no else, é number. Essa análise de fluxo de controle é o que permite tipar código JavaScript idiomático sem anotações a cada linha.

O compilador reconhece várias formas de verificação: typeof, instanceof, o operador in ('erro' in resposta), comparações de igualdade, testes de verdade (if (cliente) elimina null e undefined) e atribuições. Também entende retornos antecipados: depois de if (!cliente) return;, o resto da função sabe que cliente existe.
Uniões discriminadas
O padrão mais poderoso é a união discriminada: vários formatos com uma propriedade em comum, de valor literal, que diz qual é qual.
type Pagamento =
| { tipo: 'pix'; chave: string }
| { tipo: 'boleto'; linhaDigitavel: string; vencimento: string }
| { tipo: 'cartao'; parcelas: number };
function descrever(p: Pagamento): string {
switch (p.tipo) {
case 'pix': return `Pix para ${p.chave}`;
case 'boleto': return `Boleto com vencimento em ${p.vencimento}`;
case 'cartao': return `Cartão em ${p.parcelas}x`;
default: {
const impossivel: never = p; // se um tipo novo for criado, isto deixa de compilar
return impossivel;
}
}
}
Dentro de cada case, o compilador sabe exatamente qual formato está em jogo: p.chave só existe no ramo do Pix. E a atribuição a never no default é a verificação de exaustividade: quando alguém acrescentar { tipo: 'dinheiro' } à união, essa linha passa a dar erro, apontando cada switch que precisa tratar o caso novo. É o tipo de erro que, sem TypeScript, só apareceria em produção.
Predicados e asserções
Quando a verificação é complicada demais para o compilador inferir, você pode ensinar a ele com um predicado de tipo, uma função que retorna x is Tipo:
function ehBoleto(p: Pagamento): p is Extract<Pagamento, { tipo: 'boleto' }> {
return p.tipo === 'boleto';
}
const boletos = pagamentos.filter(ehBoleto); // o tipo do resultado já é só de boletos
O compilador confia no predicado sem conferir a lógica, então ele precisa estar certo. As funções de asserção (asserts x is Tipo) fazem o mesmo para funções que lançam erro quando a condição não vale. Desde o TypeScript 5.5, funções simples como x => x !== undefined têm o predicado inferido automaticamente, o que dispensa boa parte dos predicados escritos à mão.
Onde o narrowing se perde
O estreitamento é preciso, mas tem limites que geram dúvidas frequentes:
- Dentro de callbacks. Uma variável
letestreitada fora de uma função anônima pode ser reatribuída antes de a função rodar, e o compilador volta ao tipo largo dentro dela. Desde o 5.4, ele preserva o estreitamento quando não há atribuições depois daquele ponto. Copiar o valor para umaconstresolve os outros casos. - Em verificações guardadas numa variável.
const ehPix = p.tipo === 'pix'; if (ehPix) ...funciona desde o 4.4, desde que tudo sejaconste o valor verificado não mude. - Em acessos com chave calculada.
obj[chave]com umachavedo tipostringnão é estreitado entre duas leituras, porque o compilador não sabe se a chave é a mesma. Guarde o valor numa constante antes de verificar. - **Depois de
awaitou de chamadas.** Como visto na seção das brechas, o compilador não descarta o estreitamento de propriedades depois de uma chamada. Se a função chamada pode alterar o objeto, verifique de novo.
Genéricos e tipos utilitários
Genéricos são tipos com parâmetros. Eles permitem escrever uma função que funciona com vários tipos sem perder a informação de qual tipo entrou:
function primeiro<T>(lista: T[]): T | undefined {
return lista[0];
}
const p = primeiro([10, 20]); // number | undefined
const c = primeiro(['a', 'b']); // string | undefined
Com extends, o parâmetro ganha uma restrição. Combinado com keyof, que produz a união das chaves de um tipo, e com o acesso indexado T[K], dá para escrever funções que só aceitam nomes de propriedade válidos e devolvem o tipo certo:
function pegar<T, K extends keyof T>(obj: T, chave: K): T[K] {
return obj[chave];
}
pegar(pedido, 'total'); // number
pegar(pedido, 'totl'); // erro: 'totl' não é uma chave de Pedido
A inferência dos parâmetros de tipo
Quase nunca é preciso escrever primeiro<number>(...): o compilador deduz T a partir dos argumentos. Esse mecanismo permite uma técnica muito usada em bibliotecas, a função identidade tipada: uma função que só devolve o que recebeu, mas existe para que o compilador ligue as partes de um objeto entre si.
interface Rota<T extends z.ZodObject> {
esquema: T;
executar(dados: z.output<T>): Promise<void>;
}
const rota = <T extends z.ZodObject>(definicao: Rota<T>) => definicao;
rota({
esquema: z.object({ clienteId: z.number() }),
async executar(dados) {
dados.clienteId; // number: o tipo veio do esquema, sem anotação
},
});
Sem a função rota, seria preciso escrever o tipo dos dados duas vezes, uma no esquema e outra no parâmetro. Com ela, o compilador infere T a partir do esquema e o aplica ao executar.
Dois recursos refinam essa inferência. Os **parâmetros de tipo const** (<const T>), do 5.0, fazem o compilador inferir literais em vez de tipos largos, como se quem chamou tivesse escrito as const. E o utilitário NoInfer<T>, do 5.4, marca um parâmetro que não deve participar da inferência, útil quando um argumento deveria apenas ser conferido contra o tipo deduzido de outro.
Tipos que transformam tipos
Sobre essa base, o TypeScript permite calcular tipos a partir de outros tipos. Os tipos mapeados percorrem as chaves de um tipo e transformam cada propriedade; os tipos condicionais escolhem um tipo conforme uma condição (T extends string ? A : B), e infer extrai uma parte de um tipo. É com isso que a biblioteca padrão define os tipos utilitários, que evitam repetir a mesma estrutura com pequenas variações:

type PedidoNovo = Omit<Pedido, 'id'>; // tudo menos o id
type PedidoParcial = Partial<Pedido>; // tudo opcional, para uma edição
type Resumo = Pick<Pedido, 'id' | 'total'>; // só alguns campos
type PorSituacao = Record<Pedido['situacao'], Pedido[]>; // um objeto com uma chave por situação
type Resultado = Awaited<ReturnType<typeof carregar>>; // o que uma função async devolve
O ganho é a fonte única: quando um campo novo entra em Pedido, todos os tipos derivados se atualizam sozinhos. A recomendação é usar esses recursos com moderação. Tipos muito elaborados são difíceis de ler e geram mensagens de erro enormes. Se a definição de um tipo precisa de um comentário de dez linhas para ser entendida, provavelmente há uma forma mais simples.
Tipos condicionais distributivos e template literal
Um tipo condicional aplicado a uma união se distribui: é avaliado para cada membro separadamente, e os resultados voltam a formar uma união. É assim que Exclude e Extract funcionam:
type SemCancelado = Exclude<Pedido['situacao'], 'cancelado'>; // 'aberto' | 'faturado'
Os tipos de template literal aplicam a mesma ideia a textos. Eles permitem descrever nomes de eventos, chaves de tradução ou rotas com precisão:
type Entidade = 'pedido' | 'cliente';
type Acao = 'criado' | 'alterado';
type Evento = `${Entidade}.${Acao}`;
// 'pedido.criado' | 'pedido.alterado' | 'cliente.criado' | 'cliente.alterado'
function publicar(evento: Evento) { /* ... */ }
publicar('pedido.criado'); // ok
publicar('pedido.criada'); // erro de digitação apontado no editor
as const e satisfies
Dois recursos completam o repertório. O as const faz o compilador inferir o tipo mais específico possível, com literais e somente leitura, como no exemplo das situações. E o satisfies confere se um valor atende a um tipo sem trocar o tipo inferido:
const fretePorUf = {
MT: 25,
SP: 18,
} satisfies Record<string, number>;
fretePorUf.MT; // ok, e o editor sabe que as chaves são MT e SP
Com uma anotação comum (const fretePorUf: Record<string, number>), o compilador esqueceria quais chaves existem. Com satisfies, ele confere o formato e mantém a informação precisa.
Modelando o domínio com tipos
Até aqui, os tipos apareceram como uma forma de conferir o código. O uso mais valioso, porém, é outro: desenhar os dados de forma que estados inválidos não possam ser escritos. Quando isso dá certo, uma categoria inteira de erros desaparece, porque o código que os produziria nem compila.

Estados impossíveis
Veja um formato comum para o carregamento de uma tela:
interface EstadoTela {
carregando: boolean;
erro?: string;
pedidos?: Pedido[];
}
Parece razoável, mas ele permite combinações sem sentido: carregando: true com erro preenchido, ou pedidos e erro ao mesmo tempo. Cada tela que usa esse tipo precisa decidir o que fazer nesses casos, e cada uma decide de um jeito. A alternativa é uma união discriminada que lista só os estados reais:
type EstadoTela =
| { situacao: 'carregando' }
| { situacao: 'erro'; mensagem: string }
| { situacao: 'pronto'; pedidos: Pedido[] };
function render(estado: EstadoTela) {
switch (estado.situacao) {
case 'carregando': return 'Carregando...';
case 'erro': return estado.mensagem;
case 'pronto': return `${estado.pedidos.length} pedidos`;
}
}
Agora não existe "erro e dados ao mesmo tempo", e estado.pedidos só pode ser lido quando a situação é pronto. O mesmo raciocínio vale para um pedido que só tem data_faturamento quando está faturado, ou para um boleto que só tem data_pagamento quando foi pago.
Resultados em vez de exceções
Exceções não aparecem no tipo de uma função: nada em function salvar(p: Pedido): Promise<void> avisa que ela pode falhar por estoque insuficiente. Para erros esperados, que fazem parte da regra de negócio, uma alternativa é devolver o resultado como uma união:
type Resultado<T, E = string> =
| { ok: true; valor: T }
| { ok: false; erro: E };
function reservar(item: Item, qtd: number): Resultado<Reserva, 'sem_estoque' | 'item_inativo'> {
if (!item.ativo) return { ok: false, erro: 'item_inativo' };
if (item.estoque < qtd) return { ok: false, erro: 'sem_estoque' };
return { ok: true, valor: { itemId: item.id, qtd } };
}
Quem chama precisa verificar ok antes de ler valor, e o compilador garante isso. O safeParse do Zod, que aparece mais adiante, segue exatamente esse formato. Exceções continuam adequadas para o que é inesperado: banco fora do ar, falha de rede, um erro de programação.
Classes: o que é do TypeScript e o que é do JavaScript
As classes são um ponto em que as duas linguagens se misturam, e confundir o que vem de cada uma leva a decisões erradas. A classe em si, os campos, os métodos, a herança com extends e os campos privados com # são JavaScript. Os modificadores public, private e protected, o implements, o abstract e o override são TypeScript, e somem na compilação.
private ou #?
A diferença é mais do que estética. O private do TypeScript é verificado só na compilação: em execução, a propriedade é comum, qualquer código JavaScript pode lê-la, e o próprio TypeScript aceita o acesso por colchetes (obj['segredo']). A documentação chama isso de privacidade "branda". O # é privacidade da linguagem: o campo não existe para quem está fora da classe, nem por colchetes, nem em execução.
class Conta {
private saldoBrando = 0; // invisível para o compilador fora da classe, visível em execução
#saldo = 0; // inacessível de fora, em qualquer situação
depositar(valor: number) { this.#saldo += valor; }
}
const c = new Conta();
c['saldoBrando']; // aceito pelo compilador: a porta dos colchetes
Para regras que precisam de garantia, como o saldo de uma conta, o # é a escolha mais segura. Para código que precisa ser inspecionado em testes ou serializado por bibliotecas, o private pode ser mais prático. Em versões antigas de JavaScript, até a ES2021, o compilador reescreve os campos # usando WeakMap.
implements não muda nada na classe
Um engano comum é achar que implements "traz" os tipos da interface para a classe. Ele só confere se a classe pode ser usada como aquela interface; os tipos dos métodos continuam vindo da própria classe. Se a interface diz que validar(texto: string) e a classe declara validar(texto), o parâmetro da classe fica sem tipo (e o noImplicitAny reclama). Da mesma forma, uma propriedade opcional da interface não passa a existir na classe só por causa do implements.
override e as heranças que quebram em silêncio
Imagine uma classe base com o método calcularFrete e uma subclasse que o sobrescreve. Um dia, alguém renomeia o método da base para calcularEntrega. O método da subclasse continua lá, agora sem sobrescrever nada, e ninguém percebe. O modificador override, do 4.3, fecha essa brecha: um método marcado com override precisa existir na classe base, ou o compilador acusa erro. Com a opção noImplicitOverride, o contrário também vale: sobrescrever um método sem a palavra override vira erro, o que deixa explícita toda sobrescrita do projeto.
class CalculadoraBase {
calcularEntrega(peso: number): number { return peso * 2; }
}
class CalculadoraExpressa extends CalculadoraBase {
override calcularFrete(peso: number) { return peso * 3; }
// erro: este membro não pode ter 'override' porque não está declarado na classe base
}
As classes abstratas completam o quadro: não podem ser instanciadas diretamente e servem de base para subclasses, que são obrigadas a implementar os membros marcados como abstract.
Recursos que se liberam sozinhos: using
Arquivos abertos, conexões de banco e travas precisam ser liberados mesmo quando algo dá errado, e o jeito tradicional é um try/finally em cada uso. O TypeScript 5.2 implementou a proposta de gerenciamento explícito de recursos do TC39, que chegou ao estágio 4 e está prevista para a edição de 2027 da linguagem. Uma variável declarada com using tem o seu método [Symbol.dispose]() chamado automaticamente no fim do bloco, inclusive num return antecipado ou numa exceção:
class ArquivoTemporario implements Disposable {
readonly caminho: string;
constructor(caminho: string) { this.caminho = caminho; /* cria o arquivo */ }
[Symbol.dispose]() { /* fecha e apaga o arquivo */ }
}
function exportar() {
using arquivo = new ArquivoTemporario('/tmp/exportacao.csv');
// ... usa o arquivo; ao sair do bloco, por qualquer caminho, ele é apagado
}
O await using faz o mesmo com Symbol.asyncDispose, para liberações assíncronas, como fechar uma conexão. Com várias declarações no mesmo bloco, a liberação acontece na ordem inversa, como numa pilha. Se o corpo e a liberação lançarem erros, os dois são preservados num SuppressedError. Como nem todo ambiente traz esses recursos de forma nativa, vale conferir o suporte do runtime antes de adotar.
O tsconfig e o modo estrito
O comportamento do compilador é definido no tsconfig.json. A opção mais importante é strict, que liga um conjunto de verificações e, desde o TypeScript 6.0, vem ligada por padrão. Sem ela, o TypeScript aceita muito código perigoso:
| Opção (parte do strict) | O que faz |
|---|---|
strictNullChecks | null e undefined deixam de caber em qualquer tipo; é preciso tratá-los |
noImplicitAny | proíbe que o compilador assuma any quando não consegue inferir |
strictFunctionTypes | confere os parâmetros de funções passadas como valor |
strictPropertyInitialization | exige que as propriedades de uma classe sejam inicializadas |
useUnknownInCatchVariables | o erro de um catch passa a ser unknown, e não any |
Destas, strictNullChecks é a que mais evita erros reais: é ela que transforma o "cannot read properties of undefined" de um erro em produção num aviso no editor. Fora do strict, vale conhecer noUncheckedIndexedAccess, que faz lista[0] ter o tipo T | undefined, como é na realidade, e exactOptionalPropertyTypes, que diferencia uma propriedade ausente de uma propriedade presente com o valor undefined.
Outras opções definem o ambiente: target (a versão de JavaScript gerada), module e moduleResolution (como os import são escritos e resolvidos), noEmit (só verificar) e verbatimModuleSyntax (exige import type para importações que são só de tipo, deixando claro o que some na compilação).
Módulos: a parte que mais confunde
O JavaScript tem dois sistemas de módulos convivendo: o CommonJS (require e module.exports), nativo do Node.js desde o começo, e os módulos ECMAScript (import e export), o padrão da linguagem. O TypeScript precisa saber qual deles o seu código usa e como os caminhos dos import serão resolvidos. As combinações mais comuns:
| Situação | module | moduleResolution |
|---|---|---|
| Aplicação web com Vite ou outro empacotador | ESNext | bundler |
| Servidor Node.js com módulos ECMAScript | NodeNext | NodeNext |
| Servidor Node.js em CommonJS (comum em projetos NestJS) | NodeNext | NodeNext |
Com bundler, o compilador aceita importações sem extensão, porque o empacotador resolve os arquivos. Com NodeNext, ele segue as regras do próprio Node.js, que exigem a extensão nos caminhos relativos (escrita como .js, o arquivo que vai existir depois da compilação), e gera CommonJS ou módulos ECMAScript conforme o campo type do package.json, como o próprio Node faria. Muito erro do tipo "não encontra o módulo" vem de uma combinação que não corresponde a quem vai de fato executar o código.
Muitos projetos antigos usam "moduleResolution": "node" (também chamada de node10), que imita o Node.js de antes dos módulos ECMAScript. O TypeScript 6.0 descontinuou essa opção, e o 7.0 a removeu. A recomendação das notas de versão é migrar para nodenext quando o código roda direto no Node.js e para bundler quando passa por um empacotador ou roda no Bun. Para projetos CommonJS que não podem mudar tudo de uma vez, o 6.0 passou a aceitar a combinação de "module": "commonjs" com "moduleResolution": "bundler", apresentada como o caminho de atualização mais simples para muitos deles.
Migrando um projeto JavaScript, passo a passo
Ligar o modo estrito num projeto que nasceu sem ele costuma revelar centenas de erros. O mesmo vale para trazer um projeto inteiro de JavaScript para TypeScript. A estratégia que funciona é gradual:
- Comece verificando o JavaScript. Acrescente um
tsconfig.jsoncomallowJsecheckJsenoEmit. O compilador passa a apontar erros nos arquivos.js, usando a inferência e os comentários JSDoc, sem nenhuma mudança de extensão. - Coloque a verificação na integração contínua. Mesmo com poucas regras ligadas,
tsc --noEmita cada push impede que o projeto piore enquanto a migração anda. - Renomeie os arquivos aos poucos. Comece pelas folhas: utilitários e modelos de dados, que não dependem de muita coisa. Cada arquivo renomeado para
.tsganha anotações nas fronteiras. - Descreva os dados centrais primeiro. Os tipos de pedido, cliente e produto são usados em todo lugar; tipá-los cedo espalha o benefício pelo projeto inteiro.
- Ligue o rigor uma opção por vez.
noImplicitAnyprimeiro, porque mostra onde faltam tipos; depoisstrictNullChecks, que costuma gerar mais trabalho e mais ganho; depois ostrictcompleto. - Corrija por pasta, não pelo projeto inteiro. Uma pasta de cada vez, com revisões pequenas, é muito mais fácil de revisar do que uma mudança gigante.
- Não aceite regressão. Código novo entra já no padrão estrito. Comentários
@ts-expect-errorcom explicação marcam os pontos ainda pendentes e avisam quando deixam de ser necessários.
JSDoc: tipos sem mudar a extensão
O primeiro passo da lista merece mais atenção, porque para alguns projetos ele é o destino, e não só o começo. Com checkJs ligado, o compilador lê tipos escritos em comentários JSDoc e verifica arquivos .js com o mesmo rigor dos .ts. Uma linha // @ts-check no topo liga a verificação num arquivo só.
// @ts-check
/** @import { Pedido } from './tipos' */
/**
* @typedef {{ sku: string, quantidade: number }} Item
*/
/**
* @param {Pedido} pedido
* @param {number} percentual
* @returns {Pedido}
*/
export function aplicarDesconto(pedido, percentual) {
return { ...pedido, total: pedido.total * (1 - percentual / 100) };
}
A tag @typedef declara um tipo, @param e @returns anotam a função, @template cria genéricos e @satisfies corresponde ao operador satisfies. A tag @import traz tipos de outro arquivo sem criar nenhuma importação em execução: os nomes importados só existem dentro dos comentários. O resultado é um código que roda em qualquer lugar sem etapa de compilação e que o editor entende tão bem quanto um arquivo TypeScript. O preço é a verbosidade: tipos elaborados ficam difíceis de ler em comentários, e por isso muitos projetos guardam as definições maiores num arquivo .d.ts e as importam com @import. O TypeScript 7 refez o suporte a JavaScript para analisá-lo de forma mais parecida com os arquivos .ts; entre as mudanças, um valor usado como tipo precisa de typeof, e algumas formas antigas de anotação deixaram de ser aceitas.
Tipos não existem em execução: valide na fronteira
Este é o ponto que mais se esquece. Como os tipos são apagados, eles não garantem nada sobre dados que vêm de fora: o corpo de uma requisição, a resposta de uma API, um arquivo, uma variável de ambiente, o localStorage. Escrever const pedido = (await r.json()) as Pedido não converte nada; só diz ao compilador "confie em mim".

A prática correta é tratar todo dado externo como unknown e validá-lo na fronteira com um esquema que existe em execução. Bibliotecas como o Zod resolvem as duas metades de uma vez: o esquema valida o dado em execução, e o tipo é derivado dele, sem precisar escrevê-lo duas vezes.
import { z } from 'zod';
const pedidoSchema = z.object({
cliente_id: z.number().int().positive(),
itens: z.array(z.object({
produto_id: z.number().int(),
quantidade: z.number().positive(),
})).min(1, 'O pedido precisa de pelo menos um item.'),
observacao: z.string().max(500).optional(),
});
type PedidoEntrada = z.infer<typeof pedidoSchema>; // o tipo sai do esquema
const resultado = pedidoSchema.safeParse(corpoDaRequisicao);
if (!resultado.success) {
// responde 400 com as mensagens de erro
} else {
criarPedido(resultado.data); // aqui o dado é PedidoEntrada de verdade
}
Dentro do sistema, entre funções do mesmo código, os tipos bastam. Na borda, onde o mundo entra, é preciso conferir.
Onde ficam as fronteiras
A lista de fronteiras é maior do que parece à primeira vista:
- Requisições recebidas pela sua API: corpo, parâmetros de rota, parâmetros de consulta e cabeçalhos. Todos chegam como texto ou JSON sem garantia nenhuma.
- Respostas de outras APIs. Um fornecedor muda um campo sem avisar, e o erro aparece longe dali. Validar a resposta faz a falha aparecer no ponto de entrada, com uma mensagem clara.
- Variáveis de ambiente. Uma porta que não é número ou uma URL ausente devem impedir o serviço de subir, e não derrubá-lo na primeira requisição.
- Mensagens de filas e eventos. O produtor e o consumidor podem estar em versões diferentes. Um esquema com versão explícita evita surpresas.
- Armazenamento local e arquivos. O que foi gravado por uma versão antiga do seu próprio sistema também é dado de fora.
- A saída de modelos de linguagem. Um JSON pedido a uma IA precisa ser validado como qualquer outra entrada.
Transformar, além de validar
Esquemas também transformam. Um campo de formulário chega como texto e precisa virar número; uma data chega como texto ISO e precisa virar Date; um texto vazio pode significar "apagar o valor". O Zod tem z.coerce para conversões simples e .transform para regras próprias. Por isso existem dois tipos derivados: z.input<typeof esquema> (o que entra) e z.output<typeof esquema> (o que sai, igual a z.infer).
const filtro = z.object({
pagina: z.coerce.number().int().min(1).default(1),
busca: z.string().trim().transform(t => t || undefined).optional(),
});
filtro.parse({ pagina: '3', busca: ' ' }); // { pagina: 3, busca: undefined }
Outras bibliotecas de validação
O Zod é a mais popular, mas não a única. O Valibot tem uma API em funções separadas, pensada para gerar pacotes menores no navegador. O ArkType escreve os esquemas numa sintaxe parecida com a dos próprios tipos. O TypeBox gera esquemas no formato JSON Schema, útil quando outra ferramenta, como uma documentação OpenAPI, precisa ler o mesmo contrato. Os autores dessas bibliotecas criaram uma especificação comum, o Standard Schema, que permite a frameworks e bibliotecas de formulários aceitarem qualquer uma delas. A escolha importa menos do que o hábito: validar sempre, na entrada, e derivar o tipo do esquema.
TypeScript no ecossistema
As bibliotecas escritas em JavaScript publicam os seus tipos em arquivos .d.ts, junto com o pacote ou no repositório comunitário DefinitelyTyped, instalado como @types/nome. Projetos que preferem não ter etapa de compilação podem escrever JavaScript com comentários JSDoc e ligar checkJs, recebendo a mesma verificação. E os runtimes mais novos, Deno e Bun, executam TypeScript diretamente, assim como as versões atuais do Node.
Frameworks como Angular e NestJS foram escritos em TypeScript e usam os tipos para muito mais do que conferir erros: o NestJS, por exemplo, usa os metadados das classes para montar a injeção de dependência. No front-end, React, Vue e Svelte têm suporte completo, e ferramentas como Prisma e tRPC geram tipos a partir do esquema do banco ou da API, ligando as pontas do sistema.
No servidor: decoradores e metadados
O NestJS depende de duas opções do compilador, experimentalDecorators e emitDecoratorMetadata. A primeira habilita a versão antiga dos decoradores (@Injectable(), @Controller()), anterior ao padrão oficial que chegou no TypeScript 5.0. A segunda faz o compilador emitir os tipos dos parâmetros do construtor como dados em execução, que o framework lê para saber o que injetar em cada classe. É uma das poucas situações em que um tipo do TypeScript deixa um rastro no JavaScript gerado, e por isso essas opções não combinam com ferramentas que só apagam tipos.
No navegador: componentes e formulários
Em React, os tipos descrevem as propriedades de cada componente, e o editor passa a avisar quando uma tela usa um componente com uma propriedade faltando ou com o tipo errado. Eventos têm tipos próprios (React.ChangeEvent<HTMLInputElement>), e os ganchos como useState inferem o tipo pelo valor inicial. Nos formulários, bibliotecas como o React Hook Form aceitam um esquema do Zod como validador: o mesmo esquema confere os campos na tela e fornece o tipo dos valores, e a regra de "campo obrigatório" fica escrita num lugar só.
Estendendo os tipos de terceiros
Cedo ou tarde, um projeto precisa acrescentar algo a um tipo que não é seu: um campo usuario no objeto de requisição de um framework, uma variável global criada por um script da página, um método novo num protótipo. O TypeScript resolve isso com a mesclagem de declarações. Duas interface com o mesmo nome, no mesmo escopo, viram uma só, com os membros das duas. Com declare module, a mesma ideia vale para uma interface exportada por outro módulo:
// tipos/framework.d.ts
import 'framework-http';
declare module 'framework-http' {
interface Requisicao {
usuario?: { id: string; perfil: 'leitura' | 'escrita' };
}
}
Depois disso, todo código que recebe uma Requisicao enxerga o campo usuario, com o tipo certo. Para o escopo global, a forma é declare global { ... }, escrita dentro de um módulo. A documentação aponta dois limites: uma ampliação só pode remendar declarações que já existem, e não criar declarações novas no nível de cima do módulo, e exportações default não podem ser ampliadas.
Essa é a principal diferença entre interface e type segundo o Handbook: um type não pode ser reaberto para ganhar propriedades novas, enquanto uma interface sempre pode. Por isso o Handbook sugere, como regra prática, usar interface até precisar de algo que só o type oferece, e é a reabertura que permite a quem usa uma biblioteca acrescentar o que precisa. O cuidado é o de sempre: a ampliação é uma promessa ao compilador, e alguém precisa preencher o campo em execução, normalmente o mesmo trecho de código que valida a entrada.
Desempenho do compilador
Em projetos pequenos, o tsc é rápido o bastante para ninguém pensar nele. Em bases grandes, a verificação pode levar minutos, e o editor começa a demorar para mostrar os erros. Algumas causas e soluções conhecidas:
- Tipos muito elaborados. Tipos condicionais recursivos e uniões com milhares de membros obrigam o verificador a fazer muitas comparações. Simplificar um tipo central pode acelerar o projeto inteiro.
- Retornos sem anotação em funções exportadas. O compilador precisa inferir o tipo em cada arquivo que importa a função. Anotar o retorno corta esse trabalho e deixa a interface do módulo explícita. A opção
isolatedDeclarations, do 5.5, exige essas anotações e permite que outras ferramentas gerem os.d.tssem rodar o verificador. - Verificar o que não é seu.
skipLibChecke uma lista deincludebem definida evitam que o compilador gaste tempo com pastas de saída, testes gerados ou dependências. - Carregar tipos que ninguém usa. Até o 5.x, o compilador incluía por padrão todos os pacotes
@typesinstalados, às vezes centenas. No 6.0, o padrão da opçãotypespassou a ser uma lista vazia, e cada projeto declara o que usa ("types": ["node"]). Segundo as notas de versão, projetos que já declaravam essa lista viram o build ficar de 20% a 50% mais rápido.
Para descobrir onde está o tempo, tsc --extendedDiagnostics mostra quanto cada etapa levou e quantos tipos foram criados, e tsc --generateTrace grava um rastro detalhado que pode ser aberto em ferramentas de análise de desempenho. A reescrita do compilador em Go, que deu origem ao TypeScript 7, ataca o problema pela raiz: código nativo e verificação em paralelo.
O TypeScript 7 e o compilador nativo
Desde 2012, o compilador do TypeScript foi escrito em TypeScript e rodou como JavaScript. Isso tinha vantagens, como o compilador funcionar no navegador, no playground oficial, e ser fácil de embutir em outras ferramentas, mas também um teto: o código JavaScript roda numa thread principal e tem pouco controle sobre como os dados ficam na memória. Em bases de código muito grandes, abrir um arquivo no editor e esperar os primeiros erros levava muitos segundos; no código do próprio VS Code, cerca de 17,5.
Por que Go
O anúncio, em março de 2025, prometia reduzir a maior parte dos tempos de build em dez vezes e diminuir bastante o uso de memória. A escolha da linguagem surpreendeu muita gente, e a equipe explicou os motivos numa discussão pública. O mais importante era a compatibilidade: a ideia era portar o compilador, linha a linha, e não reescrevê-lo do zero, para que o novo produzisse exatamente os mesmos resultados. Go idiomático se parece muito com o estilo do código original, o que tornava a tradução mais direta. Pesaram também o controle sobre o layout da memória, a coleta de lixo (que, segundo a equipe, não atrapalha um compilador) e a facilidade de percorrer as árvores e grafos que um compilador manipula o tempo todo. O ponto fraco reconhecido foi a integração com JavaScript dentro do mesmo processo, que é pior em Go do que em algumas alternativas.
O que mudou na prática
O 7.0 saiu em julho de 2026 e se instala como sempre, pelo pacote typescript, com o mesmo comando tsc. Os números publicados no anúncio comparam o 6.0 com o 7.0 nas configurações padrão: a verificação do código do próprio VS Code caiu de 125,7 para 10,6 segundos, e outros projetos grandes de código aberto ficaram entre 7,7 e 8,9 vezes mais rápidos. A análise, a verificação e a geração de código rodam em paralelo; a opção experimental --checkers define quantas threads de verificação usar (quatro por padrão), e com oito o mesmo projeto do VS Code caiu para 7,5 segundos. No editor, o tempo até o primeiro erro ao abrir um arquivo nesse projeto caiu para menos de 1,3 segundo.
O 7.0 também consolidou as mudanças preparadas pelo 6.0. Entre os novos padrões estão strict ligado, module como esnext e a lista types vazia. Saíram de vez opções como target: es5, baseUrl, moduleResolution node e classic, os formatos de módulo amd, umd e systemjs e a possibilidade de desligar esModuleInterop. No 6.0, a opção "ignoreDeprecations": "6.0" ainda silenciava esses avisos; no 7.0, eles são erros.
O que ainda pede cuidado
O 7.0 não traz a interface de programação (API) que outras ferramentas usavam para conversar com o compilador; a equipe informou que uma API nova, diferente da antiga, deve vir no 7.1. Enquanto isso, o pacote @typescript/typescript6 oferece o comando tsc6 e a API do 6.0, para que as duas versões convivam no mesmo projeto, o que atende ferramentas que dependem dessa API, como o typescript-eslint. O suporte de editor para linguagens embutidas, como Vue, Svelte, Astro, MDX e os templates do Angular, também continua apoiado no 6.0 por enquanto.
Para um projeto comum, o caminho mais seguro é em duas etapas: primeiro atualizar para o 6.0 e resolver todos os avisos de descontinuação, que são exatamente as opções que deixam de existir; depois trocar para o 7.0 e comparar os erros com os da versão anterior. A opção stableTypeOrdering, do 6.0, ajuda nessa comparação, porque faz a versão antiga ordenar os tipos internamente como a nova, e assim as diferenças que sobram são as que importam.
Como ler uma mensagem de erro
As mensagens do TypeScript assustam no começo, principalmente as longas. Três hábitos ajudam:
- Leia o código e a última linha. Cada erro tem um número, como
TS2322(tipo não atribuível) ouTS2339(propriedade não existe), que facilita a busca. Nas mensagens com várias linhas recuadas, a última costuma ser a causa real; as de cima explicam o caminho até ela. - Procure onde o tipo nasceu. Se o erro diz que
string | undefinednão cabe emstring, a pergunta não é "como calar o erro", e sim "de onde veio esseundefined". Passar o mouse sobre as variáveis no editor mostra o tipo inferido em cada ponto. - Anote para localizar. Quando o erro aparece longe da causa, acrescentar o tipo de retorno de uma função ou o tipo de uma variável intermediária faz o compilador apontar o lugar exato onde a expectativa e a realidade divergem.
Um exemplo típico:
error TS2345: Argument of type '{ id: number; total: string; }' is not assignable
to parameter of type 'Pedido'.
Types of property 'total' are incompatible.
Type 'string' is not assignable to type 'number'.
A última linha diz tudo: o total chegou como texto. A causa provável é um valor lido de um formulário ou de um JSON sem conversão, e a correção certa é converter e validar na fronteira, e não trocar o tipo de total para string | number.
Testando os próprios tipos
Quando um projeto tem tipos que trabalham, como um Resultado<T, E> usado em todo lugar, uma função identidade que liga esquema e dados ou um tipo derivado de uma tabela do banco, esses tipos são código e podem quebrar. Uma mudança inocente num tipo utilitário pode transformar um retorno preciso em any, e nenhum erro aparece: tudo passa a compilar, inclusive o que não deveria.
A técnica mais simples usa o próprio compilador. Um @ts-expect-error diante de uma linha que deveria falhar vira um teste: se um dia a linha passar a compilar, o comentário fica sem uso e o compilador acusa erro.
// tipos.teste.ts: verificado pelo tsc, nunca executado
const r = reservar(item, 2);
// @ts-expect-error: não pode ler valor sem conferir ok
r.valor;
if (r.ok) {
const reserva: Reserva = r.valor; // aqui precisa compilar
}
Ferramentas de teste levam a ideia adiante. O Vitest tem o expectTypeOf, que descreve o tipo esperado de forma legível (expectTypeOf(primeiro([1, 2])).toEqualTypeOf<number | undefined>()). Arquivos terminados em .test-d.ts são tratados como testes de tipo: com a opção --typecheck, o Vitest chama o tsc por baixo e transforma os erros de tipo em testes que falham, sem executar esses arquivos. Para a maioria dos projetos de aplicação, alguns testes assim nos tipos centrais bastam; em bibliotecas, eles são tão importantes quanto os testes de comportamento.
Erros comuns
- **
anycomo saída de emergência.** Cadaanydesliga a verificação em tudo o que toca, e ele se espalha: o resultado de uma operação sobreanytambém éany. Prefiraunknowne prove o formato. A regrano-explicit-anydo typescript-eslint ajuda a manter isso. - **
aspara calar o compilador.** Uma asserção de tipo não converte nada. Se você precisa deascom frequência, o modelo de tipos está errado. A exceção legítima é um ponto único e bem conhecido, como a criação de um tipo com marca. - **O operador
!à toa.**cliente!.nomediz "isso nunca é nulo". Quando é, o erro volta em produção. Prefira umifcom um retorno antecipado, ou uma função que lança um erro com uma mensagem útil. - Confiar no tipo de dados externos. JSON, formulários e APIs precisam de validação em execução.
- **
@ts-ignoreem vez de@ts-expect-error.** O segundo avisa quando o erro deixa de existir; o primeiro esconde para sempre. - Tipos espertos demais. Tipos condicionais aninhados que ninguém consegue ler custam mais do que protegem.
- Duplicar formatos à mão. Um tipo para a API, outro para o banco e outro para a tela, todos escritos separadamente, divergem. Derive de uma fonte.
- Esquecer que compilar não é verificar. Ferramentas rápidas não conferem tipos. Rode
tsc --noEmitna integração contínua. - Campos opcionais para tudo. Um tipo em que tudo é opcional não protege nada e espalha verificações de
undefinedpelo código. Use uniões discriminadas para os estados e deixe opcional só o que de fato pode faltar.
Perguntas frequentes
Vale a pena usar TypeScript num projeto pequeno? Na maioria dos casos, sim. O custo de configuração é baixo com as ferramentas atuais, e o ganho no editor (autocompletar e erros apontados na hora) aparece desde o primeiro arquivo. Em scripts descartáveis de poucas linhas, JavaScript com JSDoc e checkJs é um meio-termo razoável.
Se eu tenho testes, preciso de tipos? Eles se complementam. Os tipos cobrem todas as chamadas do projeto, inclusive as que nenhum teste exercita, mas não dizem se o cálculo está certo. Os testes conferem o comportamento, mas só nos casos que alguém escreveu. Com tipos, os testes podem se concentrar na regra de negócio, e não em conferir se uma função recebeu um número.
Por que o editor mostra um erro que a integração contínua não mostra, ou o contrário? Quase sempre são versões diferentes do TypeScript ou configurações diferentes. O editor pode estar usando a versão embutida nele, e não a do projeto. No VS Code, o comando para selecionar a versão do TypeScript do espaço de trabalho resolve o primeiro caso; para o segundo, confira se o editor e a integração contínua leem o mesmo tsconfig.json.
O TypeScript substitui a validação no servidor? Não. Os tipos somem antes de o código rodar. Tudo o que chega de fora precisa ser validado em execução, e o tipo deve sair do esquema de validação, para que as duas coisas nunca divirjam.
Boas práticas
- Mantenha o
strictligado (o padrão desde o 6.0) e não abra mão destrictNullChecks. - Deixe o compilador inferir; anote parâmetros de funções, retornos de funções públicas e fronteiras de módulos.
- Modele estados com uniões discriminadas e feche os
switchcom verificação de exaustividade. - Use
unknownpara tudo o que vem de fora e valide com esquemas, derivando os tipos deles. - Prefira uniões de literais e
as constaenum. - Derive tipos (
Pick,Omit,z.infer, tipos gerados do banco) em vez de repetir formatos. - Confira os tipos em toda integração contínua, separadamente do build.
- Mantenha a mesma versão do TypeScript no editor, na integração contínua e no build.
- Trate erros esperados como parte do tipo de retorno, e deixe exceções para o inesperado.
Como usamos TypeScript na Gothor
O TypeScript é a linguagem da maior parte dos sistemas da Gothor, nas interfaces e nos serviços. O que mais pesa no dia a dia é o que este artigo descreve: o modo estrito ligado, dados de fora validados na fronteira antes de virar um tipo confiável e a verificação de tipos rodando a cada mudança, junto com os testes. Um campo renomeado vira um erro de compilação, e não um defeito descoberto por um cliente.
Resumo
- TypeScript é JavaScript com tipos estáticos opcionais; o compilador verifica e depois apaga os tipos. Não muda o desempenho nem valida nada em execução.
- Ferramentas rápidas só apagam tipos; a verificação é feita pelo
tsc --noEmit, no editor e na integração contínua. - A tipagem é estrutural: vale o formato, e não o nome. Marcas resolvem os casos em que isso atrapalha.
- O sistema de tipos é frouxo de propósito em alguns pontos (arrays covariantes, métodos bivariantes, narrowing depois de chamadas); vale conhecê-los.
unknowné a forma segura de dizer "não sei";anydesliga a verificação;nevermarca o impossível.- O narrowing estreita uniões a cada verificação, e uniões discriminadas com exaustividade pegam casos esquecidos.
- Genéricos,
keyof, tipos mapeados e condicionais permitem derivar tipos, como fazemPartial,Pick,OmiteRecord. - Bons tipos tornam estados impossíveis irrepresentáveis e deixam erros esperados visíveis no retorno das funções.
- O modo estrito, especialmente
strictNullChecks, é o que mais evita erros reais; a migração funciona melhor aos poucos. - Tipos não existem em execução: dados externos devem ser validados na fronteira, com esquemas como os do Zod, e o tipo deve sair do esquema.
- O TypeScript 7, de julho de 2026, trouxe o compilador portado para Go, cerca de dez vezes mais rápido; o 6.0 foi a ponte, com
strictpor padrão e opções antigas removidas.
Para ir além
- Documentação do TypeScript, o ponto de partida do item no Wisdom, e o TypeScript Handbook.
- Narrowing, genéricos e tipos utilitários, no Handbook.
- Referência do tsconfig, com cada opção explicada, e a página sobre módulos.
- Os objetivos de projeto do TypeScript, com o "não objetivo" de um sistema de tipos provadamente correto.
- Desempenho do compilador, na wiki oficial.
- Notas de versão do TypeScript e o anúncio do compilador nativo, no blog oficial.
- Os anúncios do TypeScript 6.0 e do TypeScript 7.0, e a discussão sobre a escolha do Go.
- Classes, enums, mesclagem de declarações e tipos em JSDoc, no Handbook.
- O playground do TypeScript, para testar tipos no navegador.
- Executando TypeScript no Node.js e a referência da remoção de tipos no Node.js.
- A proposta Type Annotations, no TC39, e os testes de tipos no Vitest.
- Documentação do Zod, do Prisma e a especificação Standard Schema.
Este post foi escrito com a ajuda da Juniper, a assistente de IA da Gothor, e revisado pela nossa equipe antes de ir ao ar.


