fuelen

fuelen

Mold - a tiny, zero-dependency parsing library for external payloads

Mold parses JSON APIs, webhooks, HTTP params and other external input into clean Elixir terms - coerces types, renames keys, checks structure, and returns {:ok, result} or {:error, errors} with traces.

Cheatsheet | Documentation | Hex

Mold.parse(:integer, "42")              #=> {:ok, 42}
Mold.parse(:date, "2024-01-02")         #=> {:ok, ~D[2024-01-02]}

Mold.parse(%{name: :string, age: :integer}, %{"name" => "Alice", "age" => "25"})
#=> {:ok, %{name: "Alice", age: 25}}

# Options refine the type
Mold.parse({:integer, min: 0, max: 100}, "50")              #=> {:ok, 50}
Mold.parse({:atom, in: [:draft, :published]}, "draft")       #=> {:ok, :draft}

# Any function works as a type
Mold.parse(&Version.parse/1, "1.0.0")
#=> {:ok, %Version{major: 1, minor: 0, patch: 0}}

# Errors include the path to the failing value
Mold.parse(%{items: [%{name: :string}]}, %{"items" => [%{"name" => "A"}, %{}]})
#=> {:error, [%Mold.Error{reason: {:missing_field, "name"}, trace: [:items, 1, :name], ...}]}

Types are plain data - atoms, tuples, maps, functions. No macros, no structs. A type is just a value you can build at runtime, store in a variable, or compose dynamically.

Mold follows the Parse, don’t validate approach. You parse at the boundary, and from that point on you work with clean Elixir terms. Mold handles structural correctness - business logic is a separate layer.

Also supports: source key mapping with propagation, union types, recursive types, and shared options (nilable, default, in, transform, validate) on all types.

Happy to answer any questions :slightly_smiling_face:

https://github.com/fuelen/mold

Most Liked

fuelen

fuelen

What’s the pitch for using this library over Ecto?

Good and hard question :slight_smile:

Just more minimal for cases where you otherwise wouldn’t need Ecto?

Being minimal wasn’t a goal, it’s a side effect. The main issue is that Ecto simply can’t cover everything I need.

From my experience Ecto struggles when it meets the reality of marginal APIs. Ecto works well when you control the input data format - when you write your own API, you define the contract and can build around Ecto’s features and limitations.

But consider parsing responses from a third-party server. With Ecto you have two options: schemas or schemaless changesets. With schemas, every nested structure requires a new module with a struct definition + changeset functions. That’s a lot of ceremony. And when it comes to actually saving that data, you end up converting those structs back to maps anyway, because Ecto’s cast won’t accept a foreign struct. So what was the point? Structs aren’t that convenient in this context, especially when fields are selected dynamically and you need to distinguish between not requested fields and nil values. The schemaless approach is limited too - no nested maps/lists of maps. José has a prototype GitHub - josevalim/schemecto: Schemaless Ecto changesets with support for nesting and JSON Schemas · GitHub that covers nested structures, but I need more.

External APIs are not as stable as we’d like them to be and even (or that would be better to say especially? :laughing:) from huge corporations like Microsoft, I regularly get broken data (even invalid JSON!! but that’s nother topic). For example, their recipient type looks like this:

{"emailAddress": {"address": "user@example.com", "name": "Alice"}}

camelCase keys, unnecessary nesting - I don’t need that emailAddress wrapper. The address field might contain something that’s not an email at all, sometimes it’s empty! In Mold, I define a custom email type, flatten the nesting via source paths:

email = {:string, format: ~r/^[^\s@]+@[^\s@]+$/}

recipient = {:map, fields: [
  email: [type: email, source: ["emailAddress", "address"]],
  name: [type: :string, source: ["emailAddress", "name"]]
]}

Mold.parse(recipient, %{"emailAddress" => %{"address" => "user@example.com", "name" => "Alice"}})
# {:ok, %{email: "user@example.com", name: "Alice"}}

{:map, fields: [...]} here is the expanded form because field options like :source require it. For simple cases there’s a shortcut: %{name: :string, age: :integer} which is much more compact.

As you might have guessed, email above is a custom type :slight_smile: In Ecto, you’d create a module implementing the Ecto.Type behaviour with 4 required callbacks. When I think about parsing external data, I only care about cast. Why do I need dump/load that will never be used?

If I want to drop invalid records and to continue working with what is left, I can use reject_invalid:

recipients = {[recipient], reject_invalid: true}

Custom validations in Ecto are verbose. Here is an example from the docs, :title appears 3 times:

changeset = validate_change changeset, :title, fn :title, title ->
  if title == "foo" do
    [title: {"cannot be foo", additional: "info"}]
  else
    []
  end
end

In Mold, options live with the type. :integer is one type, {:integer, min: 0} is a more precise type, just like saying positive_integer. You don’t bolt validation rules onto a type after the fact, you refine the type itself. The validation defined above would be just {:string, validate: &(&1 != "foo")}.

Ecto also forces you to write an error reason every time, even when a generic “invalid value” would be enough, especially when the end user won’t see it anyway. Mold’s validate keeps simple things simple. When you do need a rich error reason, you’d have to write a function:

title = fn title ->
  with {:ok, title} <- Mold.parse(:string, title) do
    if title == "foo" do
      {:error, :banned_title}
    else
      {:ok, title}
    end
  end
end

I think about extending validate in the future to accept :ok | {:error, reason} when you need a custom reason, but for now a function-as-type covers anything.

Mold doesn’t build error messages for the end user, so you’d write your own formatter. Mold.Error is well-documented and that shouldn’t be hard.

How about other dedicated parsing libs?

Tarams — Tarams v1.9.0 - might look similar at first glance, but the API is different. It’s very map-centric - everything starts with a map, so if a response is just a list of items, you’d need to wrap it. Mold’s API is more compact and I’m not sure you can make it shorter, the syntax is close to Elixir typespecs:

# tarams
%{users: [type: {:array, %{name: [type: :string]}}]}

# mold
%{users: [%{name: :string}]}

# typespecs
%{users: [%{name: String.t()}]}

Also, tarams doesn’t support an :atom type, so if you want to validate an enum, you’re limited to strings. In Mold {:atom, in: [:draft, :published]} returns an atom.

Zoi — Zoi v0.18.4 - a relatively new library, Mold’s core was built before Zoi appeared. Zoi has a larger API surface. Zoi is built with Zod (TypeScript) in mind - it brings that mental model to Elixir. Mold was built with Elixir in mind. The API surface of Zoi inherits a lot from TypeScript’s type system - optional vs nullable vs nullish, union vs discriminated_union vs intersection.

Zoi expects atom keys by default - coerce: true on a map enables string keys. But string keys are what you get from JSON, HTTP params, and even Phoenix controllers. So in practice you need coerce: true almost everywhere. Mold defaults to string keys because that’s what external data looks like.

Compare:

# zoi
Zoi.map(%{
  name: Zoi.string(),
  age: Zoi.integer(coerce: true),
  address: Zoi.map(%{city: Zoi.string(), zip: Zoi.string()}, coerce: true),
  tags: Zoi.array(Zoi.string())
}, coerce: true)

# mold
%{
  name: :string,
  age: :integer,
  address: %{city: :string, zip: :string},
  tags: [:string]
}

Zoi seems to aim at being a central place for all schema definitions in a project here is post from the author Zoi - schema validation library inspired by Zod - #42 by phcurado. Mold is specifically for the periphery of your application - parsing external data at the boundary. That’s why Mold defaults to string keys and doesn’t accept both string and atom keys at the same time - no temptation to use it in domain logic where it doesn’t belong.


I wanted to write more, but I’m tired :sleeping_face: I hope it helps

14
Post #5
fuelen

fuelen

v0.2.0 is out, plus a small companion package.

What’s new in Mold v0.2.0

  • source option now works on :union, :list, and :tuple (previously only on :map), so you can describe nested source keys uniformly across container types.
  • validate functions can return :ok / :error / {:error, reason} in addition to the plain true / false. The {:error, reason} variant lets you surface a custom reason in the resulting Mold.Error without having to wrap the check in a custom parse function.
  • Dynamic maps (keys/values) now collect all entry errors in one pass instead of stopping at the first failure, consistent with how fields already behaved.
  • Two new guides in the docs: Formatting errors and Using with HTTP clients.

Full changelog: mold/CHANGELOG.md at main · fuelen/mold · GitHub

tesla_middleware_mold

The HTTP clients guide describes a pattern for declaring expected response shapes at the call site. For Tesla users, that pattern is now packaged as a ready-to-use middleware:

Tesla.get(client, "/users/#{id}", opts: [mold: %{200 => user_schema(), 404 => nil}])

A 200 with the declared shape is accepted, 404 is accepted without parsing the body, anything else fails the request. Because the check runs inside the middleware chain, unexpected statuses and schema mismatches become failed Tesla requests and show up in Tesla’s telemetry, instead of silent problems surfacing later in the code.

Hex: tesla_middleware_mold | Hex
Docs: Tesla.Middleware.Mold v0.1.0 — Documentation

fuelen

fuelen

Yes, you’re right, that’s opinionated. But the workaround is simple - just define a function that does parsing as you’d want :slight_smile:

yes_no = fn                                                                                                                                                                                                      
    "yes" -> {:ok, true}                                                                                                                                                                                         
    "no" -> {:ok, false}                                                                                                                                                                                           
    _ -> {:error, :invalid}                                                                                                                                                                                        
  end

iex> Mold.parse(yes_no, "yes")
{:ok, true}
iex> Mold.parse(yes_no, "true")
{:error, [%Mold.Error{reason: :invalid, value: "true", trace: nil}]}

Where Next?

Popular in Announcing Top

martinthenth
Hello everybody :wave: Recently, some of my colleagues talked about database ids and uuids and their problems, and I remembered the pain...
New
mischov
import Meeseeks.CSS html = HTTPoison.get!("https://news.ycombinator.com/").body for story &lt;- Meeseeks.all(html, css("tr.athing")) do...
New
brainlid
LangChain is short for Language Chain. An LLM, or Large Language Model, is the “Language” part. This library makes it easier for Elixir a...
New
maltoe
Hello! Came here to announce ChromicPDF, a pet project PDF generator I’ve been working on for the past few months. Why another PDF gener...
New
kip
ex_cldr provides localisation and internationalisation support based upon the data from the Unicode CLDR project. Unicode released CLDR ...
407 12840 120
New
tmbb
I’ve been working on two packages (not on hex.pm yet) to build admin interfaces for phoenix apps: bureaucrat - which contains a bunch ...
New
ahamez
Hi everyone, I’ve been working on this protobuf library for 3 years. We use it in the company I work for, EasyMile, to communicate with ...
New
type1fool
WebAuthnLiveComponent WebAuthnComponents See this post about renaming the package. Passwordless authentication for Phoenix LiveView app...
New
cjen07
parameterized pipe in elixir: |n&gt; edit: negative index in |n&gt; and mixed usage with |&gt; are supported example: use ParamPipe ...
New
Jskalc
Hi! Today, after a couple weeks of development I’ve released v0.1 of LiveVue. It’s a seamless integration of Vue and Phoenix LiveView, i...
New

Other popular topics Top

albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
chrismccord
As promised, the first release candidate of Phoenix 1.3.0 is out! This release focuses on code generators with improved project structure...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
marick
I had some trouble figuring out how to make many-to-many associations work. Once I got it working, I wrote a blog post. Because I’m a nov...
New
PeterCarter
There are pre-rolled solutions for other frameworks that do work. However, Phoenix does not seem to have these. Have people had good expe...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New

We're in Beta

About us Mission Statement