Índice
- TypeScript decorativo: quando o compilador compila mas não protege
- Diagnóstico rápido: você tem TypeScript decorativo?
any: o buraco no sistema de tipos- Type assertions sem verificação real
- Ignorar inferência e tipar tudo na mão
- Confundir
interfacecomtype— e fazer a escolha errada - Tipos opcionais usados errado
- Erros em funções assíncronas e
catchsem tipo - Checklist: TypeScript que realmente protege
- FAQ
Segundo o State of JS 2024, TypeScript é usado por mais de 78% dos desenvolvedores JavaScript. Mas há uma diferença enorme entre usar TypeScript e usar TypeScript de forma que realmente protege seu código.
A versão silenciosa do problema: você configura o TypeScript, o projeto compila, o VS Code para de sublinhar seus erros — e você acha que está seguro. Não está. Boa parte do código que passa na compilação não está sendo protegido de verdade. Está apenas silenciando os erros de uma forma que o compilador aceita.
Este artigo cobre os sete padrões mais comuns que transformam TypeScript em um teatro de tipagem: código que parece protegido, compila sem reclamar — e quebra em runtime exatamente como quebraria em JavaScript puro.
Se você já olhou para um
TypeError: Cannot read properties of nullem produção num projeto TypeScript e pensou "mas como isso passou?", esse artigo é a resposta.
TypeScript decorativo: quando o compilador compila mas não protege
Tem uma versão do TypeScript que não protege nada. Ela compila sem erros, o VS Code não reclama, e o runtime quebra do mesmo jeito que quebraria em JavaScript puro.
// Esse código compila limpo — e quebra em runtime
function processarPedido(pedido: any) {
return pedido.itens.reduce((acc: any, item: any) => acc + item.preco, 0);
}
processarPedido(null); // TypeError: Cannot read properties of null
Se você leu any três vezes nesse snippet e achou normal, esse artigo é pra você.
O TypeScript te dá exatamente o que você pediu. Se você pede menos proteção — com any, assertions cegas, e opcionais em tudo — é exatamente isso que você recebe. O compilador não vai te salvar de você mesmo.
Diagnóstico rápido: você tem TypeScript decorativo?
Antes de mergulhar nos erros, faça o diagnóstico em 30 segundos. Abra qualquer arquivo do seu projeto e responda:
- Você tem
strict: false(ou semstrict) notsconfig.json? - Você usa
anypelo menos uma vez por arquivo em média? - Você usa
as AlgumTipoem retornos defetchouJSON.parse? - Você adiciona
!(non-null assertion) para o compilador parar de reclamar? - Seus
catch(e)acessame.messagediretamente sem verificarinstanceof Error?
Se você respondeu "sim" para dois ou mais, continue lendo — há brechas reais na sua proteção de tipos. Cada item acima corresponde a um dos sete erros abordados abaixo.
any: o buraco no sistema de tipos
any desliga a verificação de tipos completamente. Uma variável do tipo any aceita qualquer atribuição, qualquer acesso de propriedade, qualquer chamada de método — sem erro, sem aviso.
// Tudo isso compila sem reclamar
let dado: any = { nome: "Ana" };
dado = 42;
dado = null;
console.log(dado.nome.toUpperCase()); // TypeError em runtime se dado for null
O problema não é usar any uma vez. É o padrão que começa quando você tipa o retorno de fetch como any porque não sabe o formato, ou quando tipifica o parâmetro como any para o TypeScript parar de reclamar. A partir daí, any se espalha porque any é contagioso: qualquer coisa que recebe um any vira any também.
// O tipo se espalha silenciosamente
function buscarUsuario(id: string): any {
return fetch(`/api/users/${id}`).then(r => r.json());
}
const usuario = await buscarUsuario("123");
// usuario: any
// usuario.email: any
// usuario.email.toLowerCase(): any — sem erro, mesmo que email não exista
O que usar no lugar:
// Tipo explícito para o retorno esperado
interface Usuario {
id: string;
nome: string;
email: string;
}
async function buscarUsuario(id: string): Promise<Usuario> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Usuario;
}
any vs unknown: a diferença que muda tudo
| Comportamento | any | unknown |
|---|---|---|
| Aceita qualquer valor | ✅ sim | ✅ sim |
| Permite acesso a propriedades sem verificação | ✅ sim (sem erro) | ❌ não (erro de compilação) |
| Exige narrowing antes de usar | ❌ não | ✅ sim |
| Contaminação de tipo | ✅ se espalha | ❌ contida |
| Indicado para | Raramente (código de baixo nível) | catch, dados externos, retornos genéricos |
Na prática: troque any por unknown sempre que você genuinamente não souber o tipo. O compilador vai te forçar a verificar antes de usar — que é exatamente o comportamento que você queria do TypeScript desde o início.
Quando any é legítimo: quase nunca em código de aplicação. Em código de utilitário genérico de baixo nível — onde unknown ou generics não encaixam — pode fazer sentido, mas são casos raros. Se você está usando any para o compilador parar de reclamar, esse é um sinal de que precisa entender melhor o tipo real, não silenciar o aviso.
⚠️ Atenção: ative
"noImplicitAny": truenotsconfig.json. Sem essa flag, o TypeScript silenciosamente infereanyem vários contextos — parâmetros de função sem tipo, variáveis inicializadas sem valor, callbacks emforEachsem anotação. Com ela ativa, o compilador força você a ser explícito. Isso vai parecer chatice nas primeiras semanas. É exatamente o ponto.
{
"compilerOptions": {
"strict": true
}
}
Type assertions sem verificação real
as diz ao compilador: "eu sei que esse valor é desse tipo, pode confiar em mim". O compilador confia. Se você estiver errado, descobre em runtime.
// Assertion cega — compila, quebra em runtime
const resposta = await fetch("/api/config");
const config = await resposta.json() as ConfigApp;
console.log(config.timeout.toString()); // TypeError se timeout vier como string ou undefined
O uso correto de as é para situações onde você tem informação que o compilador não tem acesso — não para silenciar erros que o compilador está certo em apontar.
O erro mais frequente em PRs: usar as para converter tipos incompatíveis em vez de entender por que os tipos não batem.
// Errado: forçando tipos incompatíveis
const elemento = document.getElementById("btn") as HTMLButtonElement;
elemento.disabled = true; // quebra se o elemento não existir ou não for button
// Certo: verificar antes de assumir
const elemento = document.getElementById("btn");
if (elemento instanceof HTMLButtonElement) {
elemento.disabled = true; // TypeScript sabe que é HTMLButtonElement aqui
}
Validação real de dados externos com Zod
Para dados externos (API, JSON.parse, localStorage): use uma biblioteca de validação. O as não valida — apenas silencia. O zod faz as duas coisas:
import { z } from "zod";
const ConfigSchema = z.object({
timeout: z.number(),
baseUrl: z.string().url(),
debug: z.boolean().default(false),
});
type Config = z.infer<typeof ConfigSchema>;
const config = ConfigSchema.parse(await resposta.json());
// Se o JSON não bater com o schema: ZodError em runtime — explícito, tratável
// Se bater: config é tipado corretamente — garantia real, não promessa
💡 Dica: use
asapenas quando você realmente sabe mais que o compilador — narrowing manual, integração com API que retorna tipo genérico, ou código de interop. Se você está usandoaspara o erro sumir, o problema não foi resolvido.
Ignorar inferência e tipar tudo na mão
O TypeScript tem inferência de tipos excelente. Em muitos contextos, anotar o tipo explicitamente é redundante — e às vezes atrapalha.
// Redundante — TypeScript já infere isso
const nome: string = "Ana";
const idade: number = 28;
const ativo: boolean = true;
const ids: number[] = [1, 2, 3];
// Deixe o TypeScript inferir — o tipo é igualmente seguro
const nome = "Ana";
const idade = 28;
const ativo = true;
const ids = [1, 2, 3];
Onde a redundância tem custo real: quando você anota manualmente com um tipo mais amplo do que o necessário, e perde a precisão que a inferência daria.
// Você perdeu a informação de que status é especificamente uma dessas strings
const status: string = "ativo";
// TypeScript infere: status: "ativo" — tipo literal, mais preciso
const status = "ativo";
// Para objetos que devem ser constantes, as const vai ainda mais longe
const STATUS = {
ATIVO: "ativo",
INATIVO: "inativo",
PENDENTE: "pendente",
} as const;
type StatusUsuario = typeof STATUS[keyof typeof STATUS];
// StatusUsuario: "ativo" | "inativo" | "pendente"
Quando anotar explicitamente faz sentido
- Parâmetros de função — inferência não funciona em parâmetros, TypeScript precisa que você declare
- Retorno de função pública — documentação e contrato explícito para quem consome
- Variáveis que serão atribuídas depois —
let usuario: Usuario | null = null - Quando a inferência retorna um tipo mais amplo do que você quer
// Parâmetros sempre anotados — TypeScript não tem como inferir de fora
function calcularDesconto(preco: number, percentual: number): number {
return preco * (1 - percentual / 100);
}
// Retorno explícito em funções públicas — contrato claro
async function buscarPedidos(userId: string): Promise<Pedido[]> {
// se você mudar a implementação e o retorno real deixar de bater com Promise<Pedido[]>,
// o TypeScript vai reclamar aqui — não no código que consome a função
}
Qual a diferença entre interface e type no TypeScript e quando usar cada um?
Essa pergunta aparece em todo onboarding de TypeScript. A resposta honesta é que para a maioria dos casos, são intercambiáveis. Mas as diferenças existem e importam em contextos específicos.
// interface — pode ser estendida depois (declaration merging)
interface Usuario {
id: string;
nome: string;
}
interface Usuario {
email: string; // válido — funde com a declaração anterior
}
// type — não pode ser redeclarado
type Produto = {
id: string;
nome: string;
};
// type Produto = { preco: number }; // Erro: Duplicate identifier 'Produto'
O declaration merging de interfaces é usado por bibliotecas para permitir que você estenda tipos existentes — Request do Express, por exemplo. Se você está construindo uma biblioteca ou um módulo com contratos extensíveis, interface é a escolha.
Diferenças práticas para código de aplicação
// type permite union e intersections que interface não suporta
type Resultado<T> = { sucesso: true; dados: T } | { sucesso: false; erro: string };
type Admin = Usuario & { permissoes: string[] }; // intersection
// interface é melhor para objetos com herança
interface Animal {
nome: string;
}
interface Cachorro extends Animal {
raca: string;
}
A regra simples que funciona para a maioria:
- Objetos que representam entidades do domínio (usuário, pedido, produto):
interface - Unions, intersections, tipos utilitários, aliases de primitivos:
type - Em caso de dúvida: escolha um padrão para o projeto e seja consistente
O que não fazer é misturar aleatoriamente sem critério — isso cria inconsistência que complica o onboarding de novos devs no projeto.
Tipos opcionais usados errado
O operador ? em TypeScript marca uma propriedade como T | undefined. É poderoso — e fácil de usar errado de duas formas opostas.
Problema 1: opcional em tudo para o compilador parar de reclamar
// Todo campo opcional — o tipo não diz nada útil
interface Pedido {
id?: string;
itens?: ItemPedido[];
total?: number;
criadoEm?: Date;
}
function calcularTotal(pedido: Pedido) {
return pedido.itens?.reduce((acc, item) => acc + item.preco, 0); // number | undefined
// quem consome precisa tratar undefined — mas esse undefined nunca deveria existir
}
Se itens é obrigatório para um Pedido existir, não deve ser opcional. O TypeScript não vai te ajudar a descobrir isso — você precisa pensar sobre o que faz sentido no domínio.
Problema 2: não usar optional chaining quando deveria
// TypeScript avisa mas o dev ignora
interface Config {
timeout?: number;
}
function aplicarConfig(config: Config) {
setTimeout(() => executar(), config.timeout); // Tipo 'number | undefined' não é 'number'
// Muitos devs adicionam um `as number` aqui — solução errada
}
// Trate o undefined explicitamente
function aplicarConfig(config: Config) {
setTimeout(() => executar(), config.timeout ?? 5000);
}
💡 Dica: o operador
??(nullish coalescing) retorna o operando direito quando o esquerdo énullouundefined— diferente do||que também ativa para0e"". Para valores numéricos e strings que podem ser zero ou vazios, sempre use??.
// || ignora 0 e "" — comportamento errado para números e strings
const timeout = config.timeout || 5000; // se timeout for 0, usa 5000
// ?? só ignora null e undefined
const timeout = config.timeout ?? 5000; // se timeout for 0, usa 0
Non-null assertion (!) tem o mesmo problema do as
// Você está prometendo que isso nunca é null — o compilador acredita
const btn = document.getElementById("meu-btn")!;
btn.addEventListener("click", handler); // TypeError se o elemento não existir
Use ! apenas quando você tem certeza absoluta e verificou — não como atalho para o compilador parar de reclamar.
Erros em funções assíncronas e catch sem tipo
Em TypeScript, o parâmetro de catch tem tipo unknown por padrão (com useUnknownInCatchVariables, que faz parte do strict). Muitos devs reagem a isso desligando a flag ou adicionando as Error sem verificar.
// Assumption perigosa — nem todo erro é instância de Error
try {
await operacaoAssincrona();
} catch (e) {
console.error((e as Error).message); // pode quebrar se alguém fizer: throw "string de erro"
}
// Verificar antes de acessar
try {
await operacaoAssincrona();
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
} else {
console.error("Erro desconhecido:", e);
}
}
Utilitário para reutilizar em todo o projeto
Para projetos com muitas operações assíncronas, um utilitário pequeno elimina a repetição:
function isError(e: unknown): e is Error {
return e instanceof Error;
}
function mensagemDeErro(e: unknown): string {
if (isError(e)) return e.message;
if (typeof e === "string") return e;
return "Erro desconhecido";
}
try {
await operacaoAssincrona();
} catch (e) {
logger.error(mensagemDeErro(e));
}
Retorno explícito em funções assíncronas
// Retorno implícito vira Promise<Usuario | undefined | null | ...>
async function buscarOuCriarUsuario(email: string) {
const existente = await db.user.findUnique({ where: { email } });
if (existente) return existente;
return db.user.create({ data: { email } });
}
// Retorno explícito — contrato claro
async function buscarOuCriarUsuario(email: string): Promise<Usuario> {
const existente = await db.user.findUnique({ where: { email } });
if (existente) return existente;
return db.user.create({ data: { email } });
}
Checklist: TypeScript que realmente protege
Use isso como referência antes de abrir um PR ou revisar código novo:
| Item | O que verificar |
|---|---|
✅ strict: true no tsconfig | noImplicitAny, strictNullChecks e mais estão ativados? |
✅ Zero any no código de aplicação | Se tem any, é intencional e documentado? |
✅ as com verificação real | Type assertions têm validação antes ou usam instanceof? |
| ✅ Dados externos validados | Retorno de fetch, JSON.parse, localStorage passa por schema (zod, valibot)? |
| ✅ Inferência aproveitada | Variáveis locais sem anotação redundante? |
| ✅ Retornos de função pública tipados | Funções exportadas têm tipo de retorno explícito? |
| ✅ Opcionais com razão | Campos opcionais em interfaces são realmente opcionais no domínio? |
✅ ?? no lugar de || para números/strings | Valores 0 e "" não são tratados como ausentes? |
✅ catch trata unknown | Parâmetros de catch verificam instanceof Error antes de acessar .message? |
| ✅ Non-null assertion com certeza | ! só onde você verificou ou tem garantia do DOM? |
FAQ
Quando usar inferência de tipo e quando anotar explicitamente no TypeScript?
Deixe o TypeScript inferir onde ele consegue fazer isso corretamente — inicializações diretas (const x = 42), retornos de funções curtas, variáveis locais evidentes. Anote explicitamente em parâmetros de função (sempre), retornos de funções públicas, e variáveis que serão atribuídas depois ou que precisam de um tipo mais restrito do que a inferência daria. A regra não é "anote tudo" nem "nunca anote" — é "anote onde agrega informação".
Qual a diferença entre interface e type no TypeScript e quando usar cada um?
Para objetos do domínio e contratos de API, use interface — é a convenção mais comum e suporta extensão via declaration merging. Para unions, intersections, tipos condicionais e aliases de primitivos, use type. Em projetos novos, o mais importante é escolher um critério e manter consistente. O TypeScript em si não impõe uma escolha — mas o código de uma base inteira misturando os dois sem critério é desnecessariamente confuso.
TypeScript any vs unknown: qual a diferença e por que unknown é mais seguro?
any desabilita a verificação de tipos completamente — você pode fazer qualquer coisa com um any sem erro. unknown é o oposto seguro: você pode atribuir qualquer coisa a unknown, mas para fazer qualquer operação precisa antes narrowar o tipo (com typeof, instanceof, ou type guard). Use unknown para valores cujo tipo você genuinamente não sabe — parâmetros de catch, dados externos, retornos genéricos. Use any raramente e com intenção.
Por que o TypeScript tipifica o parâmetro catch como unknown e como tratar corretamente?
Com strict: true (ou useUnknownInCatchVariables: true diretamente), o TypeScript trata o parâmetro de catch como unknown — porque qualquer coisa pode ser jogada com throw, não só instâncias de Error. A solução correta é verificar com instanceof Error antes de acessar .message. A solução errada é adicionar as Error sem verificar ou desligar a flag.
Como evitar que any se espalhe pelo código?
Ative "noImplicitAny": true e configure o ESLint com a regra @typescript-eslint/no-explicit-any como warn ou error. Isso não proíbe any completamente — proíbe o uso silencioso. Se você precisar usar any, precisa fazer isso de forma explícita e o lint vai sinalizar para revisão. Para dados externos, use zod ou valibot na borda — isso cria uma fronteira onde unknown entra e tipo validado sai, sem que any precise aparecer no meio.