skip to content
-> all posts

650k downloads: parse, don't validate

/ 7 min read /
elixir peri oss

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.

comments

reply on Bluesky