Não valide, faça parsing — em linguagens que não querem, como TypeScript
(cekrem.github.io)- Quando verificações como
if (user.email)ficam espalhadas pelo código TypeScript, o fato já verificado não permanece no tipo, então partes mais profundas da pilha de chamadas continuam desconfiando da mesma condição - Um parser recebe entrada bruta e devolve um tipo mais restrito ou informações de falha, permitindo que o restante do programa confie em fatos já validados, como
EmailAddress - Em TypeScript, que usa um sistema de tipos estrutural,
stringeEmailnão se separam naturalmente, então recorre-se a tipos branded baseados emunique symbole a asserçõesasrestritas para imitar fronteiras nominais - Uma união discriminada como
Parsed<T>expõe sucesso e falha na assinatura de tipo, mas como não existe uma expressãomatchdedicada, é preciso escrever manualmente uma verificação exaustiva usandonever - Zod, io-ts e valibot conseguem gerar juntos o parser e os tipos TypeScript a partir de um schema, mas a disciplina de fazer parsing em cada fronteira antes de tratar entradas externas como tipos de domínio continua sendo responsabilidade do desenvolvedor
Validação descarta informação; parsing a preserva no tipo
- O princípio Parse, don’t validate de Alexis King coloca no centro a diferença entre validador e parser
- Um validador decide “esse valor está ok” e depois deixa o fluxo seguir com um boolean ou uma exceção
- Um parser recebe entrada bruta e produz um tipo mais preciso ou devolve o motivo da falha
- Se os tipos continuam amplos, como
User.email: stringeUser.age: number, mesmo queisValidUser(user): booleanretorne sucesso, o TypeScript não se lembra desse fato - Depois, em código como
emailService.send(user.email, ...),user.emailcontinua sendo apenas uma string comum, que pode ser uma string vazia,"hello"ou"definitely not an email" - Esse fluxo de revalidar a mesma condição em vários lugares se aproxima do que King chama de shotgun parsing
Uma API em que o próprio tipo é a prova
- A forma desejada é uma assinatura de função como
sendWelcome(user: ValidUser), que só aceite valores já parseados - Nessa estrutura, é obrigatório passar pelo parser antes de chamar
sendWelcome, e não há necessidade de revalidação separada nem deifdefensivos dentro da função - Em Elm, isso pode ser feito de forma simples com opaque types e smart constructors, mas em TypeScript são necessários mais mecanismos para obter o mesmo efeito
Criando fronteiras nominais com tipos branded
- TypeScript usa um sistema de tipos estrutural, então tipos com o mesmo shape são tratados como iguais
stringéstring, e não existe um recurso que crie um tipo realmente diferente, como onewtypede Haskell
- O desvio usado pela comunidade é o branding ou tagging
- Uma forma simples é um campo phantom com literal de string, como
{ readonly __brand: "Email" } - Uma forma mais forte é usar como chave da marca um
unique symbolque não é exportado para fora do módulo
- Uma forma simples é um campo phantom com literal de string, como
- Os tipos de exemplo têm a forma
type Email = string & { readonly [EmailBrand]: true }etype Age = number & { readonly [AgeBrand]: true } - O campo da marca é um marcador em nível de tipo, sem existência em runtime, e faz
Emailestringserem tratados de forma diferente em tempo de compilação - A marca funciona apenas em uma direção
Emailpode ser atribuído astring- Uma
stringcomum não pode entrar diretamente comoEmail
O parser só permite asserções na fronteira de confiança
parseEmail(raw: string): Parsed<Email>devolve falha se a string não tiver@; se passar, cria o tipo branded comraw as Email- A asserção
as Emailé uma exceção aceitável porque o parser é a fronteira de confiança- Se outra parte do código fizer asserção de
stringparaEmail, o desenho se rompe - É possível colocar os parsers em um módulo separado e tratar qualquer asserção de marca fora dele como bug
- Se outra parte do código fizer asserção de
- No exemplo,
Parsed<T>tem a forma{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }- A falha não fica escondida em uma exceção; ela aparece na assinatura de tipo
- Usar discriminadores em string como
kind: "ok" | "err"faz o narrowing funcionar de forma mais honesta quando novas variantes são adicionadas depois
- O exemplo de
parseEmailé propositalmente simples; um parser real de email também precisaria lidar com trim, lowercase, validação de domínio e mais
Separando entrada bruta e tipos de domínio confiáveis
- Separar
UnvalidatedUserdeValidUserdeixa clara a diferença entre valores vindos da rede ou de outra entrada externa e valores confiáveis dentro do domínioUnvalidatedUsermantémid,emaileagecomounknownValidUserusa tipos branded comoUserId,EmaileAge
- Fazer branding também em
UserIdajuda a evitar erros como passar umOrderIdonde se espera umUserId parseUser(raw: unknown): Parsed<ValidUser>vai restringindo a entrada bruta passo a passo- Verifica se a entrada é um objeto
- Verifica a existência dos campos
id,emaileage - Verifica se
emailé uma string - Chama
parseUserId,parseEmaileparseAgeseparadamente e retorna imediatamente em caso de falha - Se tudo der certo, retorna
ValidUser
- Esse estilo é mais verboso do que em F# ou Elm, mas faz com que
sendWelcome(user: ValidUser)seja realmente seguro
Pontos em que TypeScript incomoda
- O primeiro atrito é a asserção
as Emaildentro do parser- Em uma linguagem com tipos nominais de verdade, um smart constructor pode retornar o novo tipo sem precisar “mentir”
- Como a marca em TypeScript é um marcador de tipo virtual, o parser precisa atravessar essa fronteira com uma asserção
- O segundo atrito é a verificação exaustiva
- Uniões discriminadas em TypeScript são poderosas nesse estilo, mas não existe uma expressão
matchdedicada - É preciso escrever manualmente um padrão como
const _exhaustive: never = resultnodefaultdoswitch - Se uma terceira variante for adicionada a
Parsed, a atribuição aneverfalha e o compilador aponta o local
- Uniões discriminadas em TypeScript são poderosas nesse estilo, mas não existe uma expressão
satisfiespode servir como uma escape hatch mais elegante do que castconst x = { ... } satisfies Configverifica o tipo sem ampliar desnecessariamente os tipos literais
JSON.parseretornaany, então é mais seguro anotá-lo imediatamente comounknown- Algo como
const raw: unknown = JSON.parse(input), deixando o parser decidir depois se aquilo pertence ao tipo de domínio JSON.parsenão é um validador; é uma etapa de desserialização que transforma bytes em valores JS
- Algo como
Bibliotecas como Zod reduzem repetição
- Zod, io-ts e valibot oferecem o mesmo padrão de um jeito mais conveniente do que escrever os parsers à mão
- O exemplo com Zod cria juntos o parser e o tipo TypeScript a partir de um único schema
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })z.infer<typeof ValidUserSchema>obtém o tipoValidUserSchema.safeParse(rawInput)devolvedataem caso de sucesso eerrorem caso de falha
- O
.brand()do Zod também é um recurso em nível de tipo, como uma marca manual com symbol, e não tem comportamento em runtime - A biblioteca prende parser e tipo na mesma definição e facilita manter as fronteiras, mas não impõe por si só a disciplina de usá-la em todas as fronteiras externas
- Um
Uservindo da rede não é umUserde domínio até passar por parsing, e é preciso resistir à tentação de contornar mensagens de erro com asserções de tipo
Carregar a prova no tipo, não na memória
- O pequeno princípio aqui é: “deixe o sistema de tipos carregar a prova, e não a memória das pessoas”
- Se você checa uma condição e não codifica o resultado no tipo, o código posterior passa a assumir facilmente que aquela validação já foi feita
- Em TypeScript, esse princípio é implementado com apoio de três ferramentas
- Tipos branded para imitar identidade nominal
- Uniões discriminadas para explicitar sucesso e falha
- Uma fronteira rígida entre
unknownde entradas externas e tipos de domínio confiáveis
- Nem sempre faz sentido transformar todo o código em um pipeline de parsing, mas quando o mesmo
ifdefensivo se repete em vários arquivos, isso é um sinal de que a informação a ser validada não conseguiu entrar no tipo
1 comentários
Opiniões no Lobste.rs
Se JavaScript/TypeScript entra em conflito, técnica e ergonomicamente, com o estilo de código que você quer, fico pensando se não bastaria usar uma das muitas linguagens que compilam para JS
Haskell, Elm e F# são mencionadas, e há muitas linguagens na linha que o autor parece preferir, como PureScript, js_of_ocaml, Reason, LunarML etc. O autor chegou a escrever um texto chamado Why TypeScript Won’t Save You, comparando mais com suas linguagens preferidas, e também mantém o https://learnelm.dev.
Ou talvez a comparação em si seja o objetivo: mostrar que TypeScript muitas vezes não é suficiente e incentivar a adoção de outras toolchains ou ideias
A maioria simplesmente não tem a opção ou o tempo de escolher outra linguagem
No trabalho, gosto muito de tipos com marca (branded types), mas me incomoda bastante não poder criar um Array ou TypedArray que só possa ser indexado por números com marca
TypedArray nem sequer permite armazenar números com marca — ou, mais precisamente, nem permite lê-los de volta assim. Mesmo que fosse necessário um conjunto separado de tipos, como IndexArray ou IndexTypedArray, eu queria muito que esse recurso existisse
Em um esquema de banco de dados bem complexo, se você usar tipos com marca para todos os IDs, o TypeScript consegue detectar quando você cria joins ou condições sem sentido. As assinaturas de função também ficam mais claras, e fica mais difícil cometer vários tipos de erro
Se quiser, também é possível fazer a mesma coisa com valores de TypedArray
TArray<Foo, MyEnum>. Mas isso é assunto de C++A biblioteca
stddo Zig tem um EnumArray implementado comcomptime. Ela também oferece recursos mais amplos, como usar enums densos ou esparsos para indexação e calcular o indexador correto em tempo de compilação.Estou gostando cada vez mais desse tipo de tipagem precisa. Ela impede bastante que bugs lógicos entrem na base de código