1
nyaomaru
  • Patrocinado
    Patrocinado
7 min de leitura ·

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 é um User.

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 ausente
  • nullable(...) → o valor pode ser null

À 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:

  1. A definição do meu guard corresponde ao tipo TypeScript?
  2. 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 symbol como 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:

nyaomaru/is-kit no GitHub

Se achou útil, uma ⭐ no GitHub é sempre muito bem-vinda!

Obrigado por ler! 🙌

Carregando publicação patrocinada...