650k downloads: parse, don't validate
This post is bilingual: English first, português embaixo.
peri passed 650,000 downloads on Hex. That number is abstract until you think
about what it actually is: six hundred and fifty thousand CI runs, deploys, and
mix deps.get calls pulling a validation library I wrote in my spare time. So:
thank you. Genuinely.
peri diverges from Ecto changesets on purpose, and lives alongside them happily. Ecto is a composable relational mapper I have a lot of love for; peri is the piece I wanted after good times elsewhere, parsing at the boundary in Haskell and working with plumatic schema, and later malli, in Clojure. Elixir felt like it was missing that friend, so I wrote one. If Ecto itself ever grows something in this direction, that would be a win too.
It feels like the right moment to write down the idea peri is built on, because the idea is not mine. It comes from Alexis King’s 2019 post Parse, don’t validate, which is the best eleven paragraphs ever written about data integrity, and which you should read instead of this post if you only have time for one.
What is peri?
For anyone landing here without context: peri is a small Elixir library for
describing the shape your data should have, and then checking real data against
that shape. The schema language is plain Elixir data: maps, tuples, keyword
lists, and atoms. No special syntax to learn. And schemas compose, so a shape
you defined once can be reused inside bigger ones. You can parse any Elixir
term, from a raw integer or a DateTime up to a deeply nested map, and peri
turns untrusted input, like HTTP params or a JSON payload, into data the rest
of your app can trust, or into an error you can show to a human. It depends on
nothing and doesn’t care whether you use Ecto, Phoenix, or neither. The rest of
this post is about why that shape-checking step matters.
The idea
The distinction, in Elixir terms. This is validation:
def valid_user?(params) do
is_binary(params["email"]) and is_integer(params["age"])
end
It checks the data and then throws away everything it learned. It returns
true, and true carries no proof of anything. Every function downstream gets
the same raw map and has two options: check again, or trust. Check again is
duplicated logic scattered across the codebase. Trust across module boundaries
is how nil ends up in your database.
This is parsing:
def parse_user(params) do
with {:ok, email} <- parse_email(params["email"]),
{:ok, age} <- parse_age(params["age"]) do
{:ok, %User{email: email, age: age}}
end
end
The output is a different, more structured thing than the input. A %User{} is
not just data, it is evidence: if you are holding one, somebody already checked.
King’s phrasing: a parser consumes less-structured input and produces
more-structured output, and a validator is just a parser that throws its result
away.
She also names the failure mode, borrowing from LangSec: shotgun parsing, the antipattern where validation checks are scattered through the processing code like buckshot, each one firing only when execution happens to reach it. The program can’t reject bad input up front, so by the time a check fails you may already have sent the email, charged the card, written the row. The fix is to stratify the program into two phases: parse at the boundary, then execute on data that is already proven. Push the burden of proof upward as far as it will go.
But Elixir has types now?
When I wrote about this back in 2024 (that article is, well, old), the argument was simpler: Haskell enforces proofs at compile time, Elixir has no static type system, so we carry our proofs at runtime instead, in structs, tagged tuples, and pattern matching. That is less true now, and delightfully so. Since Elixir 1.18 the language has been growing a gradual set-theoretic type system into the compiler itself, and it is worth being precise about what that means, because it makes the case for parsing stronger, not weaker.
Set-theoretic means types compose like sets: unions (integer() or nil),
intersections (and), negations (not). The compiler already understands
literal tuple types like {:ok, binary()}, closed and open maps, and it infers
all of this from your patterns and guards without you writing a single
annotation (the types cheat sheet
shows how far the notation goes). Gradual means untyped code is not invisible:
it is checked as dynamic() -> dynamic(), and then the inference engine
refines dynamic() as your code narrows it, so a variable matched against
%User{} stops being dynamic() and starts being a user.
And here is the part that matters for this post: the type system’s own
documentation draws the boundary in exactly the place King does. Calls into
untyped or same-project code are assumed dynamic(). Warnings are best-effort
by design. And dynamic() always sits at the root of a type: {:ok, dynamic()}
gets rewritten to dynamic({:ok, term()}), because you cannot be gradual about
half a structure. In other words, the type system can reason beautifully about
the %User{} flowing through your business logic, but the JSON that just
arrived over the network is dynamic() at the root, and somebody has to do the
narrowing, once, in one place, at runtime. That somebody is a parser.
The roadmap points the same direction: typed structs are next, user-facing
signatures come after, and José Valim’s
data evolution with set-theoretic types
explores how libraries could widen their data definitions across versions
without breaking anyone, using structural subtyping (a struct is typed by what
it actually contains, not by its name) and revisions. Read it and notice how
much of it is about the same obsession: what shape is the data, at every point
it can flow, and who is allowed to promise what. Even in a fully typed Elixir
future, the boundary parse does not disappear. It is the moment dynamic()
becomes a type the compiler can trust.
peri is that boundary
Which is the whole point of the library:
defmodule MyApp.Schemas do
import Peri
defschema :user, %{
name: {:required, :string},
email: {:required, :string},
age: {:integer, {:gte, 18}},
role: {:enum, [:admin, :user, :guest]}
}
end
MyApp.Schemas.user(%{name: "Zoey", email: "zoey@zeetech.io", age: 30, role: :admin})
# => {:ok, %{name: "Zoey", email: "zoey@zeetech.io", age: 30, role: :admin}}
MyApp.Schemas.user(%{name: "Zoey", age: 12})
# => {:error, %Peri.Error{}}
Note what comes back on success: not :ok, not true, but the data itself,
normalized (atom keys, defaults filled, types guaranteed). Note what comes back
on failure: an error struct you can pattern match, traverse, and render, with
Peri.Error.humanize/1 for when you just want %{email: ["is required"]}.
Errors as data, because errors are data. And since HTTP params arrive as all
strings, there is coercion too, so %{"page" => "2"} parses into %{page: 2}
right at the controller boundary, exactly where King says the parse belongs.
So, 650k. Thank you to everyone who filed an issue, sent a PR, argued with me
about coercion semantics (you were right), or just quietly added {:peri, ...}
to a mix.exs somewhere. Parse at the boundary, pattern match on the proof,
and go validate something. Sorry, go parse something.
650 mil downloads: faça parsing, não validação
peri passou de 650 mil downloads no Hex. É um número abstrato até você pensar
no que ele realmente é: seiscentos e cinquenta mil pipelines de CI, deploys e
mix deps.get baixando uma biblioteca de validação que eu escrevi no meu tempo
livre. Então: obrigada. De verdade!
peri diverge dos changesets do Ecto de propósito, e convive com eles feliz da vida. O Ecto é um mapeador relacional componível pelo qual eu tenho muito carinho; peri é a peça que eu quis depois de bons tempos em outras terras, fazendo parsing na fronteira em Haskell e trabalhando com plumatic schema, e depois malli, em Clojure. O Elixir parecia estar sentindo falta desse amigo, então eu escrevi um. Se o próprio Ecto um dia crescer algo nessa direção, também é vitória.
Acho que é o momento certo pra escrever sobre a ideia por trás de peri, porque a ideia não é minha. Ela vem do artigo de 2019 da Alexis King, Parse, don’t validate (“faça parsing, não validação”), que são os melhores onze parágrafos já escritos sobre integridade de dados. Se você só tiver tempo de ler uma coisa hoje, leia ele, não este post.
O que é peri?
Pra quem chegou aqui sem contexto: peri é uma biblioteca pequena de Elixir pra
descrever o formato que seus dados deveriam ter, e então conferir dados reais
contra esse formato. A linguagem de schemas é dado puro de Elixir: mapas,
tuplas, keyword lists e átomos. Sem sintaxe nova pra aprender. E schemas são
componíveis, então um formato que você definiu uma vez pode ser reutilizado
dentro de formatos maiores. Dá pra fazer parsing de qualquer termo de Elixir,
de um inteiro cru ou um DateTime até um mapa profundamente aninhado, e peri
transforma entrada não confiável, como parâmetros HTTP ou um payload de JSON,
em dados nos quais o resto da aplicação pode confiar, ou num erro que você pode
mostrar pra um humano. Não depende de nada e não liga se você usa Ecto, Phoenix
ou nenhum dos dois. O resto deste post é sobre por que essa etapa de conferir
o formato importa.
A ideia
A distinção, em Elixir. Isto é validação:
def usuario_valido?(params) do
is_binary(params["email"]) and is_integer(params["age"])
end
Ela checa o dado e depois joga fora tudo o que aprendeu. Retorna true, e
true não carrega prova nenhuma. Todas as funções depois dela recebem o mesmo
mapa cru e têm duas opções: checar de novo, ou confiar. Checar de novo é lógica
duplicada espalhada pelo código. Confiar através de fronteiras de módulos é
como nil vai parar no seu banco de dados. Já vimos esse filme!
Isto é parsing:
def parse_usuario(params) do
with {:ok, email} <- parse_email(params["email"]),
{:ok, age} <- parse_age(params["age"]) do
{:ok, %User{email: email, age: age}}
end
end
A saída é uma coisa diferente e mais estruturada que a entrada. Um %User{}
não é só um dado, é uma evidência: se você está segurando um, alguém já
verificou. Nas palavras da King: um parser consome entrada menos estruturada e
produz saída mais estruturada, e um validador é só um parser que joga o
resultado fora.
Ela também dá nome ao modo de falha, emprestado do LangSec (language-theoretic security): shotgun parsing (parsing de espingarda, e o nome é ótimo), o antipadrão onde as checagens ficam espalhadas pelo código de processamento como chumbo, cada uma disparando só quando a execução chega nela. O programa não consegue rejeitar entrada inválida logo de cara, então quando uma checagem falha você talvez já tenha mandado o e-mail, cobrado o cartão, escrito no banco. A solução é estratificar o programa em duas fases: parsing na fronteira, execução depois, em cima de dados já provados. Empurre o peso da prova pra cima o máximo que der!
Mas Elixir tem tipos agora?
Quando eu escrevi sobre isso lá em 2024 (esse artigo está, bem, velho), o argumento era mais simples: Haskell garante provas em tempo de compilação, Elixir não tem sistema de tipos estático, então a gente carrega as provas em tempo de execução, em structs, tuplas tagueadas e pattern matching. Isso ficou menos verdade, e que bom! Desde o Elixir 1.18 a linguagem vem embutindo no próprio compilador um sistema de tipos gradual e teórico-conjuntista (gradual set-theoretic type system), e vale ser precisa sobre o que isso significa, porque ele deixa o argumento do parsing mais forte, não mais fraco.
Teórico-conjuntista quer dizer que tipos combinam como conjuntos: uniões
(integer() or nil), interseções (and), negações (not). O compilador já
entende tipos literais de tupla como {:ok, binary()}, mapas fechados e
abertos, e infere tudo isso dos seus patterns e guards sem você escrever uma
única anotação (a colinha de tipos
mostra até onde a notação vai). Gradual quer dizer que código sem anotação não
fica invisível: ele é checado como dynamic() -> dynamic(), e aí o motor de
inferência vai refinando o dynamic() conforme seu código estreita os
possíveis valores, então uma variável que casou com %User{} deixa de ser
dynamic() e passa a ser uma usuária.
E aqui vem a parte que importa pra este post: a própria documentação do sistema
de tipos desenha a fronteira exatamente onde a King desenha. Chamadas pra
código sem tipos ou do mesmo projeto são assumidas como dynamic(). Os avisos
são best-effort por design. E o dynamic() sempre fica na raiz do tipo:
{:ok, dynamic()} é reescrito como dynamic({:ok, term()}), porque não dá pra
ser gradual sobre metade de uma estrutura. Em outras palavras: o sistema de
tipos raciocina lindamente sobre o %User{} fluindo pela sua regra de negócio,
mas o JSON que acabou de chegar pela rede é dynamic() na raiz, e alguém
precisa fazer esse estreitamento, uma única vez, num único lugar, em tempo de
execução. Esse alguém é um parser.
O roadmap aponta na mesma direção: structs tipados são o próximo passo,
assinaturas escritas pelo usuário vêm depois, e o artigo do José Valim
data evolution with set-theoretic types
(evolução de dados com tipos teórico-conjuntistas) explora como bibliotecas
poderiam alargar suas definições de dados entre versões sem quebrar ninguém,
usando subtipagem estrutural (um struct é tipado pelo que ele realmente contém,
não pelo nome dele) e revisões. Lê ele e repara quanto dele é sobre a mesma
obsessão: qual o formato do dado em cada ponto por onde ele flui, e quem pode
prometer o quê. Mesmo num futuro de Elixir completamente tipado, o parsing de
fronteira não desaparece. Ele é o momento em que dynamic() vira um tipo em
que o compilador pode confiar.
peri é essa fronteira
Que é exatamente a razão de existir da biblioteca:
defmodule MyApp.Schemas do
import Peri
defschema :user, %{
name: {:required, :string},
email: {:required, :string},
age: {:integer, {:gte, 18}},
role: {:enum, [:admin, :user, :guest]}
}
end
Repara no que volta em caso de sucesso: não é :ok, não é true, é o próprio
dado, normalizado (chaves em átomo, defaults preenchidos, tipos garantidos). E
repara no que volta em caso de falha: um struct de erro que você pode dar
pattern match, percorrer e renderizar, com Peri.Error.humanize/1 pra quando
você só quer um %{email: ["is required"]}. Erros como dados, porque erros
são dados. E como parâmetros HTTP chegam todos como strings, tem coerção
também, então %{"page" => "2"} vira %{page: 2} logo na fronteira do
controller, exatamente onde a King diz que o parsing deve acontecer.
Então, 650 mil. Obrigada a todo mundo que abriu issue, mandou PR, discutiu
comigo sobre semântica de coerção (vocês estavam certos), ou só silenciosamente
adicionou {:peri, ...} num mix.exs por aí. Faça parsing na fronteira, dê
pattern match na prova, e vá validar alguma coisa. Ops, vá parsear alguma
coisa!
comments
-
designer de stacktrace @souenzzo.com.br
Esse padrão de descrever o formato do dado como dado é bem comum em clojure. As ferramentas geralmente possuem a validação e o parse disponível. O peri permite que vc manipule dinamicamente o dado que descreve? Posso criar um novo usuário, só que com mais campos adicionais?
-
n me zoey 🏳️⚧️ @zoedsoupe.zeetech.io
simm e eu gosto mto dessa peculiaridade da família LISP e fico encantada q elixir herde de forma mto natural esse conceito, quando bem aplicado. diria q elixir é um pouquinho LISP tbm mas sim! com peri vc consegue expandir ou rejeitar campos em runtime e tbm mesclar 2 ou mais schemas
-
-
n me zoey 🏳️⚧️ @zoedsoupe.zeetech.io
yay, it worked!
-
n me zoey 🏳️⚧️ @zoedsoupe.zeetech.io
let’s check sub threads
-
-
Shinobu @shinobu-dev.bsky.social
i fully agree! my linkedin post says that