TypeScript

TypeScript não valida nada em runtime: tipando a fronteira do sistema

Seus tipos somem na compilação. A API responde o que quiser e o any implícito entra sorrindo. Como validar de verdade na fronteira do sistema.

· 10 min de leitura

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:

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 Usuario e UsuarioSchema.parse(x) é a diferença entre uma promessa e um contrato. O as silencia o compilador; o parse verifica 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?

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.