Seu Type Guard pode divergir silenciosamente do seu tipo TypeScript 🔧
Hoi hoi! 👋
Eu sou o @nyaomaru, frontend engineer, e acabei de voltar de uma pequena viagem para Texel, uma ilha nos Países Baixos. 😸🏝️
Hoje quero falar sobre um type guard que parece completamente seguro.
const isUser = (value: unknown): value is User => {
// runtime checks...
};
Parece bom, certo?
O TypeScript sabe que, quando isUser(value) retorna true, o valor é um User.
Mas existe um pequeno problema:
O TypeScript confia nessa promessa.
Ele não prova que os seus checks em runtime realmente validam todos os campos de User.
E é aí que um type guard pode começar a divergir silenciosamente do tipo que ele deveria proteger.
Vamos ver isso! 👀
🕳️ Um Type Guard pode ficar desatualizado sem gerar erro
Imagine que começamos com este tipo:
type User = {
id: string;
name: string;
};
E um type guard escrito manualmente:
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
Até aqui, tudo está alinhado.
Mais tarde, atualizamos User:
type User = {
id: string;
name: string;
role: "admin" | "member";
};
Mas esquecemos de atualizar o guard.
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
Não existe nenhum check para role.
Mesmo assim, isso continua compilando. 😿
🧠 Por que o TypeScript não detecta isso?
Porque isto:
(value: unknown): value is User
é um user-defined type predicate.
Estamos dizendo ao TypeScript:
Confie em mim. Se esta função retornar
true, o valor é umUser.
O TypeScript consegue verificar se o tipo declarado no predicate faz sentido.
Mas ele não consegue, de forma geral, provar que uma lógica arbitrária em runtime realmente valida todas as partes desse tipo.
Então isto é possível:
const isUser = (_value: unknown): _value is User => true;
Um guard terrível.
TypeScript perfeitamente válido. 😹
O return type é um contrato escrito por nós, não uma prova gerada a partir do corpo da função.
🔄 Isso vira um problema de manutenção
A parte incômoda não é escrever o guard uma vez.
É manter estas duas coisas sincronizadas ao longo do tempo:
TypeScript type
↕
Runtime validation
Tipos mudam.
Propriedades são:
- adicionadas
- removidas
- renomeadas
- transformadas em opcionais
- alteradas para outro tipo
E, sempre que isso acontece, precisamos lembrar que algum runtime guard em algum lugar talvez também precise ser atualizado.
Se esquecermos, o compiler pode não nos avisar.
Esse é exatamente o tipo de bug que eu não quero depender da memória para evitar.
✅ E se o tipo pudesse ser o contrato?
Esse é um dos motivos pelos quais adicionei typedStruct ao is-kit.
Suponha que o tipo da aplicação já exista:
type User = {
id: string;
name: string;
age?: number;
};
Podemos construir o guard com base nesse tipo existente:
import {
isNumber,
isString,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
});
Agora o field map possui uma relação em nível de tipo com User.
Em runtime, ele continua executando uma validação comum de objeto.
Mas, em compile time, o TypeScript consegue verificar se os guards declarados correspondem ao object type que deveriam acompanhar.
💥 Agora o drift fica visível
Vamos adicionar um campo novamente:
type User = {
id: string;
name: string;
role: "admin" | "member";
age?: number;
};
Mas esquecemos de atualizar o guard:
typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
// TypeScript error:
// role is missing
});
Ótimo.
O bug de runtime virou um problema de compile time.
O mesmo acontece se usarmos um guard incompatível para um campo:
import {
isNumber,
isString,
oneOfValues,
optionalKey,
typedStruct,
} from "is-kit";
typedStruct<User>()({
id: isString,
name: isNumber,
// TypeScript error:
// User["name"] is string
role: oneOfValues("admin", "member"),
age: optionalKey(isNumber),
});
Essa é a parte que mais me importa.
typedStruct não elimina a manutenção.
Ele torna a manutenção esquecida visível.
🧩 Optional e nullable são coisas diferentes
Outro ponto em que object guards podem ficar confusos são propriedades opcionais.
Considere:
type User = {
id: string;
nickname?: string | null;
};
Existem duas ideias diferentes aqui:
nickname pode estar ausente
e:
nickname pode existir com o valor null
Esses são contratos diferentes em runtime.
Com typedStruct:
import {
isString,
nullable,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
nickname: optionalKey(nullable(isString)),
});
Agora:
isUser({ id: "user-1" });
// true
isUser({
id: "user-1",
nickname: null,
});
// true
isUser({
id: "user-1",
nickname: "Neko",
});
// true
isUser({
id: "user-1",
nickname: 42,
});
// false
Eu gosto de manter essas duas decisões explícitas:
optionalKey(...)→ a propriedade pode estar ausentenullable(...)→ o valor pode sernull
À primeira vista elas parecem parecidas, mas descrevem coisas diferentes.
🌳 Tipos aninhados também não precisam ser duplicados
Agora imagine um tipo maior:
type Account = {
readonly id: string;
readonly profile: {
readonly displayName: string;
readonly bio: string | null;
} | null;
readonly tags: readonly string[];
};
Poderíamos copiar manualmente a shape de profile para outro tipo.
Mas isso criaria mais uma coisa que pode divergir.
Em vez disso, podemos referenciar o tipo que já existe:
import {
arrayOf,
isString,
nullable,
typedStruct,
} from "is-kit";
const isProfile = typedStruct<
NonNullable<Account["profile"]>
>()({
displayName: isString,
bio: nullable(isString),
});
const isAccount = typedStruct<Account>()({
id: isString,
profile: nullable(isProfile),
tags: arrayOf(isString),
});
Esse é o modelo que eu gosto:
Reutilize o tipo existente em compile time. Componha pequenos guards em runtime.
O application type continua sendo a fonte que queremos que o guard acompanhe.
🔒 E propriedades extras em runtime?
Existe outra distinção importante.
Estas são duas perguntas diferentes:
- A definição do meu guard corresponde ao tipo TypeScript?
- Um objeto em runtime pode conter propriedades adicionais?
Por padrão, o objeto ainda pode ter keys extras.
Se você também quiser fechar a shape do objeto em runtime, pode habilitar o modo exact:
import { isString, typedStruct } from "is-kit";
type User = {
id: string;
name: string;
};
const isExactUser = typedStruct<User>()(
{
id: isString,
name: isString,
},
{
exact: true,
},
);
Então:
isExactUser({
id: "user-1",
name: "Ada",
});
// true
isExactUser({
id: "user-1",
name: "Ada",
debug: true,
});
// false
Rejeitar ou não propriedades extras é uma decisão de runtime policy.
Isso não deve ser confundido com manter a definição do guard sincronizada com o tipo TypeScript.
⚖️ Qual deve ser a source of truth?
Não acho que exista um único estilo de validação correto para todos os projetos.
A pergunta importante é:
O que já é dono da shape desses dados?
Manual predicate
const isSomething = (
value: unknown,
): value is Something => {
// custom logic
};
Ótimo quando a validação é incomum ou não é principalmente estrutural.
Guard-first
const isUser = struct({
id: isString,
name: isString,
});
Útil quando o próprio guard deve definir o tipo resultante.
Type-first
const isUser = typedStruct<User>()({
id: isString,
name: isString,
});
Útil quando User já existe e o runtime guard precisa permanecer alinhado com ele.
Schema-first
Uma schema library ou code generation pode ser uma source of truth melhor quando você precisa de coisas como:
- erros de validação estruturados
- coercion
- transforms
- defaults
- artifacts gerados
Essas abordagens resolvem problemas diferentes.
Eu não acho que todo check booleano precise virar um schema. 😸
🚫 O que typedStruct não faz
Existem alguns limites importantes.
typedStruct não gera validação em runtime a partir de um tipo TypeScript.
Tipos são apagados em runtime, então ainda precisamos declarar os guards que queremos executar.
Ele também não:
- prova que todo custom predicate é honesto
- faz coercion de valores
- retorna erros de validação estruturados e detalhados
- substitui workflows schema-first
- valida propriedades numéricas ou
symbolcomo parte do seu contrato de objeto baseado em string keys
Ele é intencionalmente menor do que isso.
O objetivo é simplesmente criar uma ponte tipada entre:
o object type que você já possui
e:
os runtime guards que você escolhe executar
🎯 A parte mais importante
O ponto principal deste artigo nem é realmente o typedStruct.
É isto:
Um type predicate é uma promessa, não uma prova.
Isto:
(value): value is User
não significa que o TypeScript inspecionou a implementação e provou que todos os campos de User foram validados.
Nós fizemos essa promessa.
Então, quando um tipo TypeScript é a source of truth, acho útil fazer o runtime guard depender estruturalmente desse tipo, em vez de depender da nossa memória para acompanhar todas as mudanças futuras.
Foi isso que eu quis ajudar a resolver com typedStruct. 😸
Se o seu guard define o tipo, use uma abordagem guard-first.
Se um tipo TypeScript existente deve definir o contrato, conecte o guard a esse tipo.
E, se você precisa de parsing mais rico, transforms, coercion ou erros detalhados, é aí que um schema começa a justificar o seu peso.
Também escrevi um guia mais completo sobre isso na documentação do is-kit:
Keep Type Guards in Sync with TypeScript Types | is-kit
E, se você gosta de pequenos Type Guards reutilizáveis em TypeScript, o is-kit também é open source:
Se achou útil, uma ⭐ no GitHub é sempre muito bem-vinda!
Obrigado por ler! 🙌