O bug chega assim: Cannot read properties of undefined (reading 'nome'), numa linha onde usuario.nome tem tipo string. O TypeScript jurou que usuario era um Usuario completo. Em produção, veio null. Como, se compilou sem um único erro?
Porque o TypeScript nunca prometeu o que você achou que ele prometeu.
Os tipos não existem em runtime
Este é o ponto que sustenta tudo o resto, e quase todo mundo aprende tarde: o TypeScript é apagado na compilação. Os tipos, interfaces, generics — tudo isso é removido, e o que roda é JavaScript puro. Aquela interface Usuario linda não existe no código que executa. É uma anotação para o compilador, não uma verificação para o programa.
interface Usuario {
id: number;
nome: string;
email: string;
}
const usuario: Usuario = await resposta.json();
// ^ isto NÃO valida nada
O await resposta.json() devolve o tipo any (ou unknown nas versões recentes com a flag certa). Ao anotar : Usuario, você não está verificando — está afirmando. Diz ao compilador "confie em mim, isto é um Usuario", e ele confia. Se a API devolveu { "id": null, "nome": "Ana" } sem o e-mail, o TypeScript não tem como saber. O erro só aparece lá adiante, quando alguém lê usuario.email.toLowerCase().
TypeScript garante consistência dentro do seu código: se A retorna um
Usuario, B pode confiar. Mas ele não tem poder algum sobre o que entra no sistema — resposta de API, corpo de request, variável de ambiente, JSON de arquivo, retorno de query. Tudo isso cruza a fronteira sem passar por checagem nenhuma.
O any que entra sorrindo
O problema piora porque o any é contagioso e silencioso. Uma vez que um valor any entra, ele desliga a verificação de tudo que toca — sem um aviso sequer:
const dados = await resposta.json(); // any
const total = dados.itens // any, sem erro
.reduce((s, i) => s + i.preco, 0); // 'i' é any, s + i.preco é any
// se 'itens' vier undefined: crash em runtime
// se 'preco' vier string: "0102030" em vez de 6
Cada acesso ali parece tipado, mas nenhum é. O reduce some strings sem reclamar e você descobre no relatório financeiro. É o pior tipo de bug: aquele que o compilador tinha todas as condições de pegar, mas não pegou, porque você desligou a proteção sem perceber ao deixar um any passar.
A regra: valide na fronteira, confie no interior
A solução não é validar em todo lugar — é validar uma vez, no ponto exato onde o dado externo entra no sistema. Depois desse ponto, o dado é confiável e o TypeScript volta a fazer seu trabalho normalmente.
Pense no seu sistema como um prédio com portaria. Lá dentro, todo mundo já tem crachá e circula livre — é o TypeScript garantindo consistência. A portaria é a fronteira: todo dado que vem de fora passa por lá e tem o crachá conferido em runtime, antes de entrar. Fronteiras típicas:
- Respostas de API e chamadas
fetch - Corpo (body) e query params de requests que você recebe
- Variáveis de ambiente (
process.envéstring | undefined, sempre) - Dados lidos de arquivo, fila ou cache
- Retorno de bibliotecas tipadas como
any
Validar na portaria em vez de espalhar checagem pelo prédio inteiro é o que mantém o interior limpo. Cada if (x != null) defensivo no meio da lógica de negócio é, quase sempre, sintoma de uma fronteira que não foi validada na entrada.
Validação em runtime com schema
A ferramenta certa transforma a afirmação de tipo numa verificação de verdade. Bibliotecas de schema como Zod, Valibot ou ArkType definem a forma do dado uma vez e entregam as duas coisas ao mesmo tempo: a checagem em runtime e o tipo estático, derivado do mesmo schema.
import { z } from 'zod';
const UsuarioSchema = z.object({
id: z.number().int(),
nome: z.string().min(1),
email: z.string().email(),
});
// o tipo é DERIVADO do schema — nunca saem de sincronia
type Usuario = z.infer<typeof UsuarioSchema>;
const bruto = await resposta.json(); // unknown
const usuario = UsuarioSchema.parse(bruto); // Usuario, validado
// ^ se a forma não bater, lança erro AQUI, na fronteira
Três coisas mudam de figura. O parse checa em runtime e lança um erro descritivo no ponto de entrada, não três funções depois. O tipo Usuario é inferido do schema, então tipo e validação nunca divergem — mudou o schema, mudou o tipo. E de parse para frente, usuario é um Usuario de verdade, com a garantia que a anotação manual só fingia dar.
A diferença entre
as UsuarioeUsuarioSchema.parse(x)é a diferença entre uma promessa e um contrato. Oassilencia o compilador; oparseverifica o dado. Um estala em produção, o outro falha na porta de entrada com uma mensagem clara.
Falhe na entrada, com controle
Validar na fronteira não significa deixar a aplicação explodir quando o dado é inválido — significa decidir conscientemente o que fazer, no único lugar onde você ainda tem contexto para isso. O parse lança; a variante safeParse devolve um resultado que você trata:
const r = UsuarioSchema.safeParse(bruto);
if (!r.success) {
// aqui você AINDA tem contexto: logue, responda 400,
// use um fallback — a decisão é sua e é local
logger.warn('payload invalido', { erros: r.error.issues });
return resposta.status(400).json({ erro: 'Dados invalidos' });
}
const usuario = r.data; // Usuario, garantido daqui pra frente
Repare no ganho: o tratamento de erro fica na fronteira, onde você sabe que veio uma resposta de API malformada e pode responder 400 ou registrar a origem. Se você deixasse o dado entrar cru, o erro estouraria lá no fundo da lógica de negócio — sem contexto, difícil de rastrear, e provavelmente como um 500 genérico.
Os suspeitos de sempre
Alguns pontos de entrada enganam porque parecem seguros. Não são.
Variáveis de ambiente
Todo valor em process.env é string | undefined. Um process.env.PORT que você trata como número é uma string — ou undefined, se a variável não foi definida no ambiente novo. Valide na inicialização e falhe cedo:
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// falha no boot se faltar algo — melhor que quebrar em produção
export const env = EnvSchema.parse(process.env);
Um segredo ausente derruba a aplicação na subida, com mensagem clara, em vez de causar um erro obscuro na primeira requisição que precisar dele.
O ORM que promete demais
Seu ORM diz que a coluna é string, mas os tipos dele descrevem o schema, não a realidade do banco. Um JSON gravado por outro sistema, uma migração que rodou pela metade, um NULL numa coluna que "não deveria" — o TypeScript acredita nos tipos gerados e não vê nada disso. Dado que vem do banco também cruza uma fronteira; em pontos críticos, vale revalidar.
O JSON.parse nu
JSON.parse tem tipo de retorno any — o cavalo de Troia clássico. Todo JSON.parse deveria ser seguido de uma validação de schema, senão você acabou de abrir um buraco any no meio do código tipado.
O custo, e onde ele não compensa
Validação em runtime não é de graça: é código a mais, uma dependência a mais e um custo de CPU por request. Onde não vale?
- Dado que nunca cruza a fronteira. Objetos criados e consumidos dentro do seu código já são garantidos pelo TypeScript. Validá-los de novo é redundância pura — o interior do prédio não precisa de portaria.
- Caminho ultra-quente e confiável. Em um hot path sob carga extrema, cujo produtor você controla de ponta a ponta, o custo do parse pode pesar. É exceção, exige medição, e não vale para nada que venha da internet.
- Protótipo descartável. Se é para jogar fora amanhã, pule. Só não deixe virar produção sem a portaria — quase todo legado começou como protótipo que ninguém revalidou.
A régua é a origem do dado, não o esforço: veio de fora do seu código, valide; nasceu dentro, confie.
Em resumo
TypeScript é um verificador estático, não um guarda de runtime. Ele garante que o seu código é consistente consigo mesmo, e absolutamente nada sobre os dados que entram de fora. Anotar : Usuario ou as Usuario num dado externo é uma promessa que ninguém checou.
Desenhe o sistema com uma portaria: valide todo dado externo em runtime no ponto de entrada, com um schema que também gera o tipo, e confie no interior a partir dali. Você troca a surpresa do undefined is not a function em produção por um erro claro na fronteira — no único lugar onde ainda dá para tratá-lo direito.