Como validar `unknown` em TypeScript sem uma biblioteca de schema? 🛡️
Hoi hoi! 👋
Eu sou @nyaomaru, um engenheiro frontend obcecado por gamba. 😸🦐
Recentemente, eu estava pensando em um problema muito comum em TypeScript.
Suponha que algum valor venha de:
- uma resposta de API
JSON.parse- local storage
- um payload de mensagem
- código de terceiros
Que tipo esse valor deveria ter antes de realmente verificarmos o que ele é?
Provavelmente este:
declare const input: unknown;
Essa parte é fácil.
A pergunta interessante é o que vem depois.
Como transformamos esse unknown em algo útil com segurança?
E será que realmente precisamos de uma biblioteca de schema completa toda vez?
Vamos ver! 👀
https://is-kit.dev/guides/validate-unknown-without-schema-library
🧱 unknown está fazendo exatamente o trabalho dele
Suponha que tenhamos:
declare const input: unknown;
input.name;
// Error
O TypeScript se recusa a deixar que a gente acesse name.
No começo, isso pode parecer irritante.
Mas o TypeScript está, na verdade, dizendo algo importante:
Temos um valor.
Ainda não sabemos o que ele é.
É exatamente isso que um valor vindo de uma boundary não confiável deveria significar.
Uma resposta HTTP não vira um User só porque nossa aplicação espera um User.
JSON parseado também não.
Por isso, gosto de pensar em unknown assim:
unknownnão significa ausência de informação de tipo.É uma descrição honesta de um valor que ainda não verificamos.
O problema não é o unknown.
O problema é como estabelecemos confiança.
😈 A saída fácil: as User
Existe um jeito bem curto de escapar disso.
type User = {
id: string;
name: string;
};
declare const input: unknown;
const user = input as User;
user.name.toUpperCase();
O TypeScript fica feliz.
Ótimo! 🎉
Exceto que nada foi realmente verificado.
Isto ainda funciona:
const input = {
id: "user-1",
name: 123,
} as unknown;
const user = input as User;
user.name.toUpperCase();
// 💥 Runtime error
as User muda aquilo em que o compiler acredita.
Ele não inspeciona o valor em runtime.
Não adiciona propriedades ausentes.
Não transforma 123 em string.
Então, se confiança em runtime importa, type assertion não é validação.
Precisamos de uma checagem real em runtime.
🛡️ Um type guard conecta runtime e TypeScript
Vamos começar pequeno.
Com is-kit, podemos criar um guard assim:
import { isString, oneOfValues, struct } from "is-kit";
const isUser = struct({
id: isString,
name: isString,
role: oneOfValues("admin", "member"),
});
Agora podemos passar unknown diretamente para ele:
declare const input: unknown;
if (isUser(input)) {
input.name.toUpperCase();
// input:
// Readonly<{
// id: string;
// name: string;
// role: "admin" | "member";
// }>
}
Essa é a parte de que eu gosto.
A mesma checagem faz duas coisas:
runtime
→ esse valor realmente corresponde?
TypeScript
→ que tipo esse valor tem depois que a checagem passa?
Não existe definição de schema separada nem type assertion.
Apenas compomos pequenos runtime predicates.
🧩 A estrutura pode crescer naturalmente
Payloads reais normalmente são um pouco mais complicados.
Talvez um usuário tenha:
- um profile opcional
- uma bio nullable
- uma lista de tags
Podemos continuar compondo guards:
import {
arrayOf,
isString,
nullable,
oneOfValues,
optionalKey,
struct,
} from "is-kit";
const isProfile = struct({
displayName: isString,
bio: optionalKey(nullable(isString)),
});
const isUser = struct({
id: isString,
role: oneOfValues("admin", "member"),
profile: optionalKey(isProfile),
tags: arrayOf(isString),
});
Então:
isUser({
id: "user-1",
role: "member",
tags: ["typescript", "guards"],
});
// true
Mas:
isUser({
id: "user-1",
role: "owner",
tags: ["typescript"],
});
// false
Cada peça continua sendo apenas um guard.
Isso significa que podemos testá-la isoladamente, reutilizá-la em outro lugar ou compô-la em algo maior.
Também existe uma diferença importante aqui:
optionalKey(...)
significa:
a propriedade pode não existir
enquanto:
nullable(...)
significa:
o valor pode ser null
São contratos de runtime diferentes, então prefiro mantê-los separados.
📦 O que safeParse acrescenta?
Um guard direto muitas vezes já é suficiente.
if (isUser(input)) {
renderUser(input);
}
Mas às vezes eu quero que o próprio resultado da validação percorra o programa.
Por exemplo:
const result = safeParse(isUser, input);
if (!result.valid) {
return { status: 400 as const };
}
return {
status: 200 as const,
user: result.value,
};
safeParse nos dá um pequeno resultado tagged:
{
valid: true,
value,
}
ou:
{
valid: false,
}
Assim o TypeScript consegue fazer narrowing com um branch normal.
A parte importante é o que safeParse não faz.
Ele não:
- clona o valor
- faz coercion
- gera um novo objeto
- transforma dados inválidos em dados válidos
Em caso de sucesso, ele retorna o mesmo valor depois que o guard o aceitou.
Então penso na diferença assim:
guard
→ boolean + narrowing
safeParse
→ tagged result + validated value
Mesma regra de runtime.
Formato de control flow diferente.
🧃 E o JSON?
Essa é uma das boundaries mais comuns.
const value = JSON.parse(body);
A parte estranha é que JSON.parse retorna any.
Então o valor inseguro pode se espalhar pelo programa antes de ser validado.
Em vez disso, podemos manter a boundary pequena:
import { safeJsonParse } from "is-kit";
declare const body: string;
const result = safeJsonParse(body, isUser);
if (!result.valid) {
return { status: 400 as const };
}
renderUser(result.value);
Conceitualmente, o fluxo é:
JSON text
↓
decode
↓
unknown
↓
guard
↓
validated value
JSON inválido e falha no guard produzem:
{
valid: false,
}
E, novamente, nada é convertido só para fazer o valor passar.
Gosto disso porque a parte insegura fica bem na boundary de decode.
🚪 Devemos permitir chaves extras?
Existe outra pequena decisão de design que aparece em object boundaries.
Suponha que esperamos:
{
username: string;
password: string;
}
Devemos aceitar isto também?
{
username: "Neko",
password: "secret",
admin: true,
}
Com struct, chaves extras são permitidas por padrão.
Isso é frequentemente útil para respostas de API, porque um servidor pode adicionar um campo novo sem quebrar um client antigo.
Mas às vezes queremos realmente um formato fechado de objeto.
Nesse caso, podemos usar:
const isCredentials = struct(
{
username: isString,
password: isString,
},
{ exact: true },
);
Agora:
isCredentials({
username: "Neko",
password: "secret",
});
// true
enquanto:
isCredentials({
username: "Neko",
password: "secret",
admin: true,
});
// false
O ponto importante não é que um modo seja sempre melhor.
É que a boundary deve expressar o contrato que realmente queremos.
🤔 Então ainda precisamos de bibliotecas de schema?
Sim.
Às vezes, absolutamente.
Um toolkit de type guards e uma schema library têm alguma sobreposição, mas não tentam resolver exatamente o mesmo problema.
Por exemplo, uma schema library provavelmente é a escolha melhor quando precisamos de:
- erros estruturados de validação com paths de campos
- vários problemas de validação ao mesmo tempo
- coercion
- valores padrão
- transformations
- tipos ou artefatos externos gerados a partir do schema
- integração profunda com form ou API frameworks
Esses são workflows maiores de validação.
is-kit faz uma pergunta mais estreita:
Esse valor satisfaz este runtime predicate?
Se sim, para que tipo o TypeScript pode fazer narrowing com segurança?
Às vezes, esse modelo mais simples é exatamente o que eu quero.
Por exemplo:
| Abordagem | Bom uso |
|---|---|
Checks inline com typeof | Uma condição local pequena |
| Type guards | Validação booleana reutilizável e narrowing |
| Schema library | Validação rica, transformations e workflows de schema |
Então não acho que a pergunta seja:
Type guards ou schema libraries — qual vence?
A pergunta mais útil é:
Qual é o menor modelo de validação que resolve esta boundary?
🎯 A parte importante
Começamos com um valor simples:
declare const input: unknown;
O atalho tentador era:
const user = input as User;
Mas isso apenas muda aquilo em que o TypeScript acredita.
Não estabelece confiança em runtime.
Um runtime guard nos dá um fluxo muito mais útil:
unknown
↓
runtime check
↓
narrowed value
E quando queremos uma API orientada a resultado:
unknown
↓
guard
↓
safeParse
↓
{ valid: true, value }
ou
{ valid: false }
Sem conversão escondida.
Sem assertion fingindo que a checagem já aconteceu.
Apenas pequenas regras de runtime que o TypeScript também consegue entender. 😸
Se quiser ver o guia completo, incluindo JSON boundaries, exact objects e custom domain refinements:
https://is-kit.dev/guides/validate-unknown-without-schema-library
E se quiser explorar o próprio is-kit:
https://github.com/nyaomaru/is-kit
Se parecer útil, uma ⭐ no GitHub é sempre muito bem-vinda!
Obrigado por ler! 🙌