A tiny, zero-dependency parsing library for external payloads.
Mold parses JSON APIs, webhooks, HTTP params and other external input into clean Elixir terms.
See the Cheatsheet for a quick reference.
Types
A Mold type is plain Elixir data. Every type is one of three things:
| Form | Example | Meaning |
|---|---|---|
| Atom | :string | Built-in type with default options |
| Function | &MyApp.parse_email/1 | Custom parse fn value -> {:ok, v} | {:error, r} | :error end |
| Tuple | {:integer, min: 0} | Type (atom or function) with options |
Options refine the type: :integer is one type, {:integer, min: 0} is a different,
more precise type — like saying positive_integer. Each combination describes
a specific set of values, not a type with separate validation rules bolted on.
Maps and lists have a shortcut syntax:
| Shortcut | Example | Expands to |
|---|---|---|
| Map | %{name: :string} | {:map, fields: [name: :string]} |
| List | [:string] | {:list, type: :string} |
Shortcuts support options via tuple: {%{name: :string}, nilable: true},
{[:string], reject_invalid: true}. These can be nested: [%{name: :string}] is a list of maps.
Shortcuts don't support field options like source: or optional:.
Use the full {:map, fields: [...]} syntax when you need those.
See the type definitions below for details and examples on each built-in type.
Shared options
All types accept the following options:
:nilable– allowsnilas a valid value.:default– substitutesvaluewhen nil. Accepts a static value, a zero-arity function, or an MFA tuple{mod, fun, args}for lazy evaluation. Implies nilable. Note: a 3-tuple{atom, atom, list}is always treated as MFA. To use such a tuple as a static default, wrap it:default: fn -> {Mod, :fun, []} end.:in– validates that the parsed value is a member of the givenEnumerable.t/0(list, range, MapSet, etc.).:transform– a function applied to the parsed value before validation. E.g.{:string, transform: &String.downcase/1}.:validate– a function that must returntruefor the value to be accepted. Runs after:transformand:in. Fails with reason:validation_failedonfalse. E.g.{:integer, validate: &(rem(&1, 2) == 0)}.
Execution order: parse → transform → in → validate.
Errors include a :trace that points to the exact failing path (list indexes and/or map keys).
Summary
Types: Basic
Atom type. Accepts atoms and strings convertible to existing atoms
(via String.to_existing_atom/1).
Boolean type. Accepts booleans, strings ("true", "false", "1", "0"), and integers (1, 0).
Float type. Accepts floats, integers (promoted to float), and strings parseable as floats.
Integer type. Accepts integers and strings parseable as integers.
String type. Accepts binaries; trims whitespace by default.
Types: Date & Time
Date type. Accepts Date.t/0 or ISO8601 date string.
Empty strings are treated as nil.
DateTime type. Accepts DateTime.t/0 or ISO8601 datetime string.
Empty strings are treated as nil.
NaiveDateTime type. Accepts NaiveDateTime.t/0 or ISO8601 datetime string (without timezone).
Empty strings are treated as nil.
Time type. Accepts Time.t/0 or ISO8601 time string.
Empty strings are treated as nil.
Types: Collections
List type. Validates each element against the given type.
Map type. Three forms
Tuple type. Validates each element positionally. Accepts both tuples and lists as input.
Types: Composite
Union type. Selects which type to apply based on the value.
Types: Custom
Function type. A bare parse_function/0 or a tuple with shared options.
A function that attempts to parse a value.
Types
Default value: a static value, a zero-arity function, or an MFA tuple.
Transform function applied to the parsed value.
Validate function. Must return true for the value to be accepted.
Functions
Parses data according to type and returns {:ok, value} or {:error, [%Mold.Error{}]}.
Parses data according to type or raises Mold.Error.t/0 on failure.
Types: Basic
Primitive scalar types: strings, integers, floats, booleans, and atoms.
@type atom_type() :: {:atom, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :atom
Atom type. Accepts atoms and strings convertible to existing atoms
(via String.to_existing_atom/1).
Options:
Examples
iex> Mold.parse({:atom, in: [:draft, :published, :archived]}, "draft")
{:ok, :draft}
iex> Mold.parse(:atom, "nonexistent_atom_xxx")
{:error, [%Mold.Error{reason: :unknown_atom, value: "nonexistent_atom_xxx"}]}
iex> Mold.parse({:atom, in: [:draft, :published]}, "archived")
{:error, [%Mold.Error{reason: {:not_in, [:draft, :published]}, value: :archived}]}
iex> Mold.parse({:atom, default: :draft}, nil)
{:ok, :draft}
@type boolean_type() :: {:boolean, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :boolean
Boolean type. Accepts booleans, strings ("true", "false", "1", "0"), and integers (1, 0).
Options:
Examples
iex> Mold.parse(:boolean, "true")
{:ok, true}
iex> Mold.parse(:boolean, "0")
{:ok, false}
iex> Mold.parse(:boolean, "yes")
{:error, [%Mold.Error{reason: :invalid_format, value: "yes"}]}
iex> Mold.parse({:boolean, default: false}, nil)
{:ok, false}
@type float_type() :: {:float, nilable: boolean(), default: default(), min: number(), max: number(), in: Enumerable.t(), transform: transform(), validate: validate()} | :float
Float type. Accepts floats, integers (promoted to float), and strings parseable as floats.
Options:
:min– minimum value (inclusive):max– maximum value (inclusive)- Shared options
Examples
iex> Mold.parse(:float, "3.14")
{:ok, 3.14}
iex> Mold.parse(:float, 42)
{:ok, 42.0}
iex> Mold.parse({:float, min: 0.0, max: 1.0}, "1.5")
{:error, [%Mold.Error{reason: {:too_large, max: 1.0}, value: 1.5}]}
@type integer_type() :: {:integer, nilable: boolean(), default: default(), min: integer(), max: integer(), in: Enumerable.t(), transform: transform(), validate: validate()} | :integer
Integer type. Accepts integers and strings parseable as integers.
Options:
:min– minimum value (inclusive):max– maximum value (inclusive)- Shared options
Examples
iex> Mold.parse(:integer, "42")
{:ok, 42}
iex> Mold.parse({:integer, min: 0, max: 100}, "150")
{:error, [%Mold.Error{reason: {:too_large, max: 100}, value: 150}]}
iex> Mold.parse(:integer, "abc")
{:error, [%Mold.Error{reason: :invalid_format, value: "abc"}]}
iex> Mold.parse({:integer, in: 1..10}, "5")
{:ok, 5}
@type string_type() :: {:string, trim: boolean(), nilable: boolean(), default: default(), format: Regex.t(), min_length: non_neg_integer(), max_length: non_neg_integer(), in: Enumerable.t(), transform: transform(), validate: validate()} | :string
String type. Accepts binaries; trims whitespace by default.
Empty strings (and whitespace-only with trim) are treated as nil.
Options:
:trim– trim whitespace before validation (default:true):format– validate against aRegex.t/0pattern:min_length– minimum string length (inclusive, in grapheme clusters):max_length– maximum string length (inclusive, in grapheme clusters)- Shared options
Examples
iex> Mold.parse(:string, " hello ")
{:ok, "hello"}
iex> Mold.parse(:string, " ")
{:error, [%Mold.Error{reason: :unexpected_nil, value: " "}]}
iex> Mold.parse({:string, nilable: true}, "")
{:ok, nil}
iex> Mold.parse({:string, min_length: 3, max_length: 50}, "hi")
{:error, [%Mold.Error{reason: {:too_short, min_length: 3}, value: "hi"}]}
iex> format = ~r/^[a-z]+$/
iex> Mold.parse({:string, format: format}, "Hello")
{:error, [%Mold.Error{reason: {:invalid_format, format}, value: "Hello"}]}
iex> Mold.parse({:string, trim: false}, " hello ")
{:ok, " hello "}
Types: Date & Time
ISO8601-based date and time types.
@type date_type() :: {:date, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :date
Date type. Accepts Date.t/0 or ISO8601 date string.
Empty strings are treated as nil.
Options:
Examples
iex> Mold.parse(:date, "2024-01-02")
{:ok, ~D[2024-01-02]}
iex> Mold.parse(:date, "2024-13-01")
{:error, [%Mold.Error{reason: :invalid_date, value: "2024-13-01"}]}
iex> year_2024 = Date.range(~D[2024-01-01], ~D[2024-12-31])
iex> Mold.parse({:date, in: year_2024}, "2025-01-01")
{:error, [%Mold.Error{reason: {:not_in, year_2024}, value: ~D[2025-01-01]}]}
@type datetime_type() :: {:datetime, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :datetime
DateTime type. Accepts DateTime.t/0 or ISO8601 datetime string.
Empty strings are treated as nil.
Options:
Examples
iex> Mold.parse(:datetime, "2024-01-02T03:04:05Z")
{:ok, ~U[2024-01-02 03:04:05Z]}
iex> Mold.parse(:datetime, ~U[2024-01-02 03:04:05Z])
{:ok, ~U[2024-01-02 03:04:05Z]}
iex> Mold.parse(:datetime, "invalid")
{:error, [%Mold.Error{reason: :invalid_format, value: "invalid"}]}
iex> Mold.parse({:datetime, nilable: true}, "")
{:ok, nil}
@type naive_datetime_type() :: {:naive_datetime, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :naive_datetime
NaiveDateTime type. Accepts NaiveDateTime.t/0 or ISO8601 datetime string (without timezone).
Empty strings are treated as nil.
Options:
Examples
iex> Mold.parse(:naive_datetime, "2024-01-02T03:04:05")
{:ok, ~N[2024-01-02 03:04:05]}
iex> Mold.parse(:naive_datetime, "invalid")
{:error, [%Mold.Error{reason: :invalid_format, value: "invalid"}]}
@type time_type() :: {:time, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :time
Time type. Accepts Time.t/0 or ISO8601 time string.
Empty strings are treated as nil.
Options:
Examples
iex> Mold.parse(:time, "14:30:00")
{:ok, ~T[14:30:00]}
iex> Mold.parse(:time, "25:00:00")
{:error, [%Mold.Error{reason: :invalid_time, value: "25:00:00"}]}
Types: Collections
Maps, lists, and tuples.
@type list_type() :: {:list, type: t(), reject_invalid: boolean(), min_length: non_neg_integer(), max_length: non_neg_integer(), nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :list
List type. Validates each element against the given type.
Bare :list validates the value is a list and returns as-is (passthrough).
The shortcut [type] can always be used instead of {:list, type: type},
including with options: {[type], opts}.
Options:
:type– the element typet/0(required):reject_invalid– drop invalid items instead of failing:min_length– minimum list length (inclusive):max_length– maximum list length (inclusive)- Shared options
Examples
iex> Mold.parse([:string], ["a", "b"])
{:ok, ["a", "b"]}
iex> Mold.parse([%{name: :string}], [%{"name" => "A"}, %{"name" => "B"}])
{:ok, [%{name: "A"}, %{name: "B"}]}
iex> Mold.parse({[:integer], min_length: 1, max_length: 3}, [])
{:error, [%Mold.Error{reason: {:too_short, min_length: 1}, value: []}]}
iex> Mold.parse({[:string], reject_invalid: true}, ["a", nil, "b"])
{:ok, ["a", "b"]}
iex> Mold.parse([:integer], ["1", "abc", "3"])
{:error, [%Mold.Error{reason: :invalid_format, value: "abc", trace: [1]}]}
@type map_type() :: {:map, fields: [ {term(), t() | [type: t(), source: source_path(), optional: boolean()]} ], keys: t(), values: t(), source: (term() -> any()), reject_invalid: boolean(), nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :map
Map type. Three forms:
- Bare
:map— validates the value is a map, returns as-is (passthrough). {:map, fields: [...]}— validates maps field-by-field. Field names are typically atoms (the defaultsourceconverts them to strings for lookup); non-atom keys require a customsource.{:map, keys: t, values: t}— homogeneous map, parses all keys and values.
Options for fields:
:fields– list of{name, type}tuples where each value is either at/0(name: :string) or a keyword list with field options::type– the field typet/0(required):source– where to read the value from. A single step or a list of steps (path), likeget_in/2. Each step can be:- function –
Accessaccessor (e.g.Access.at/1,Access.elem/1,Access.key/2) - any other term – key lookup via
Access.fetch/2(string, atom, integer, etc.)
- function –
:optional– whentrue, omit field from result when missing from input
:source– a function(field_name -> any())that derives the source key from each field name (e.g.source: &(Atom.to_string(&1) |> Macro.camelize())). Defaults to&Atom.to_string/1. Propagates recursively to nested maps, including through lists and tuples.
Options for keys/values:
Both fields and keys/values forms support:
:reject_invalid– drop items that fail parsing instead of returning an error. Forfields, only affects fields markedoptional: true— required fields still fail. Forkeys/values, drops the key-value pair that failed.
Shared options apply to all forms.
Examples
iex> Mold.parse(%{name: :string, age: :integer}, %{"name" => "Alice", "age" => "25"})
{:ok, %{name: "Alice", age: 25}}Source defaults to &Atom.to_string/1 — field names are looked up as string keys.
Custom source per field:
iex> schema = {:map, fields: [
...> user_name: [type: :string, source: "userName"],
...> is_active: [type: :boolean, source: "isActive"]
...> ]}
iex> Mold.parse(schema, %{"userName" => "Alice", "isActive" => "true"})
{:ok, %{user_name: "Alice", is_active: true}}Nested source paths:
iex> schema = {:map, fields: [name: [type: :string, source: ["details", "name"]]]}
iex> Mold.parse(schema, %{"details" => %{"name" => "Alice"}})
{:ok, %{name: "Alice"}}Access functions for advanced navigation — Access.at/1 for lists,
Access.elem/1 for tuples, Access.key/2 with a default, and more:
iex> schema = {:map, fields: [
...> lat: [type: :float, source: ["coords", Access.at(0)]],
...> lng: [type: :float, source: ["coords", Access.at(1)]]
...> ]}
iex> Mold.parse(schema, %{"coords" => [49.8, 24.0]})
{:ok, %{lat: 49.8, lng: 24.0}}Global source function (propagates to nested maps):
iex> schema = {
...> %{user_name: :string, address: %{zip_code: :string}},
...> source: &(Atom.to_string(&1) |> Macro.camelize())
...> }
iex> Mold.parse(schema, %{"UserName" => "Alice", "Address" => %{"ZipCode" => "10001"}})
{:ok, %{user_name: "Alice", address: %{zip_code: "10001"}}}Non-atom field names work with a custom source:
iex> schema = {:map,
...> source: fn {ns, name} -> Enum.join([ns, name], ":") end,
...> fields: [
...> {{:feature, :dark_mode}, :boolean},
...> {{:feature, :beta}, :boolean}
...> ]}
iex> Mold.parse(schema, %{"feature:dark_mode" => "true", "feature:beta" => "0"})
{:ok, %{{:feature, :dark_mode} => true, {:feature, :beta} => false}}Optional fields:
iex> schema = {:map, fields: [
...> name: :string,
...> bio: [type: :string, optional: true]
...> ]}
iex> Mold.parse(schema, %{"name" => "Alice"})
{:ok, %{name: "Alice"}}
iex> Mold.parse(schema, %{"name" => "Alice", "bio" => "hello"})
{:ok, %{name: "Alice", bio: "hello"}}
iex> # optional omits the field when missing, but nil still fails — use nilable on the type
iex> Mold.parse(schema, %{"name" => "Alice", "bio" => nil})
{:error, [%Mold.Error{reason: :unexpected_nil, value: nil, trace: ["bio"]}]}When a field is both optional: true and has a default, optional takes priority
for missing fields — the field is omitted from the result. The default only applies
when the field is present but nil:
iex> schema = {:map, fields: [
...> bio: [type: {:string, default: "none"}, optional: true]
...> ]}
iex> Mold.parse(schema, %{})
{:ok, %{}}
iex> Mold.parse(schema, %{"bio" => nil})
{:ok, %{bio: "none"}}Missing fields are errors:
iex> Mold.parse(%{name: :string}, %{})
{:error, [%Mold.Error{reason: {:missing_field, "name"}, value: %{}, trace: []}]}Homogeneous maps with keys/values:
iex> Mold.parse({:map, keys: :string, values: :integer}, %{"a" => "1", "b" => "2"})
{:ok, %{"a" => 1, "b" => 2}}
iex> Mold.parse({:map, keys: :atom, values: :string}, %{"name" => "Alice"})
{:ok, %{name: "Alice"}}Reject invalid — for fields, only optional fields are affected:
iex> schema = {:map, reject_invalid: true, fields: [
...> name: :string,
...> age: [type: :integer, optional: true],
...> bio: [type: :string, optional: true]
...> ]}
iex> Mold.parse(schema, %{"name" => "Alice", "age" => "nope", "bio" => nil})
{:ok, %{name: "Alice"}}
iex> Mold.parse(schema, %{"age" => "25"})
{:error, [%Mold.Error{reason: {:missing_field, "name"}, value: %{"age" => "25"}, trace: []}]}Reject invalid — for keys/values, drops the key-value pair that failed:
iex> Mold.parse({:map, keys: :string, values: :integer, reject_invalid: true}, %{"a" => "1", "b" => "nope"})
{:ok, %{"a" => 1}}
@type tuple_type() :: {:tuple, elements: [t()], nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | :tuple
Tuple type. Validates each element positionally. Accepts both tuples and lists as input.
Bare :tuple validates the value is a tuple (or list), converts lists to tuples, returns as-is.
Options:
:elements– list oft/0, one per element (required)- Shared options
Examples
iex> Mold.parse({:tuple, elements: [:string, :integer]}, ["Alice", "25"])
{:ok, {"Alice", 25}}
iex> Mold.parse({:tuple, elements: [:string, :integer]}, {"Alice", "25"})
{:ok, {"Alice", 25}}
iex> Mold.parse({:tuple, elements: [:string, :integer]}, ["only_one"])
{:error, [%Mold.Error{reason: {:unexpected_length, expected: 2, got: 1}, value: ["only_one"]}]}
iex> Mold.parse(:tuple, [1, "two", :three])
{:ok, {1, "two", :three}}
Types: Composite
Types that combine other types.
@type union_type() :: {:union, by: (any() -> any()), of: %{required(any()) => t()}, nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()}
Union type. Selects which type to apply based on the value.
The by function receives the raw value and must return a variant
that is looked up in of.
Options:
:by– function that takes the raw value and returns a variant (required):of– map of variant =>t/0(required)- Shared options
Examples
iex> schema = {:union,
...> by: fn value -> value["type"] end,
...> of: %{
...> "user" => %{name: :string},
...> "bot" => %{version: :integer}
...> }}
iex> Mold.parse(schema, %{"type" => "user", "name" => "Alice"})
{:ok, %{name: "Alice"}}
iex> Mold.parse(schema, %{"type" => "bot", "version" => "3"})
{:ok, %{version: 3}}
iex> Mold.parse(schema, %{"type" => "admin"})
{:error, [%Mold.Error{reason: {:unknown_variant, "admin"}, value: %{"type" => "admin"}}]}Use a catch-all in by to handle unexpected input types:
iex> schema = {:union,
...> by: fn
...> %{"type" => type} -> type
...> _ -> :unknown
...> end,
...> of: %{
...> "user" => %{name: :string},
...> "bot" => %{version: :integer}
...> }}
iex> Mold.parse(schema, 123)
{:error, [%Mold.Error{reason: {:unknown_variant, :unknown}, value: 123}]}
Types: Custom
Any fn value -> {:ok, parsed} | {:error, reason} | :error end is accepted wherever a type is expected. Wrap in a tuple to add shared options: {my_fn, nilable: true}.
@type function_type() :: {parse_function(), nilable: boolean(), default: default(), in: Enumerable.t(), transform: transform(), validate: validate()} | parse_function()
Function type. A bare parse_function/0 or a tuple with shared options.
Examples
iex> email_type = fn v ->
...> if is_binary(v) and String.contains?(v, "@") do
...> {:ok, String.downcase(v)}
...> else
...> {:error, :invalid_email}
...> end
...> end
iex> Mold.parse(email_type, "USER@EXAMPLE.COM")
{:ok, "user@example.com"}
iex> Mold.parse({email_type, nilable: true}, nil)
{:ok, nil}
iex> Mold.parse({email_type, default: "fallback@example.com"}, nil)
{:ok, "fallback@example.com"}
iex> # Use inside maps
iex> Mold.parse(%{email: email_type}, %{"email" => "USER@EXAMPLE.COM"})
{:ok, %{email: "user@example.com"}}
iex> # Standard library functions work as types
iex> Mold.parse(&JSON.decode/1, ~s|{"a": 1}|)
{:ok, %{"a" => 1}}Bare :error is supported (see parse_function/0):
iex> Mold.parse(&Version.parse/1, "1.0.0")
{:ok, %Version{major: 1, minor: 0, patch: 0}}
iex> Mold.parse(&Version.parse/1, "invalid")
{:error, [%Mold.Error{reason: :invalid, value: "invalid"}]}
@type parse_function() :: (any() -> {:ok, any()} | {:error, [Mold.Error.t()] | any()} | :error)
A function that attempts to parse a value.
Must return {:ok, parsed}, {:error, reason}, or bare :error.
Bare :error is converted to {:error, :invalid} — this lets you use
standard library functions like &Version.parse/1 directly as types.
Can also return {:error, [%Mold.Error{}]} — errors are passed through
with trace propagation. This is useful when a parse function calls
Mold.parse/2 internally, including recursive types:
defmodule Comment do
def parse_comment(value) do
Mold.parse(%{text: :string, replies: {[&parse_comment/1], nilable: true}}, value)
end
end
Types
Default value: a static value, a zero-arity function, or an MFA tuple.
@type source_path() :: source_step() | [source_step()]
@type source_step() :: term() | Access.access_fun(any(), any())
@type t() :: string_type() | atom_type() | boolean_type() | integer_type() | float_type() | datetime_type() | naive_datetime_type() | date_type() | time_type() | union_type() | map_type() | list_type() | tuple_type() | function_type() | %{optional(any()) => t()} | {%{optional(any()) => t()}, keyword()} | [t()] | {[t()], keyword()}
Union of all built-in and custom types accepted by parse/2 and parse!/2.
Transform function applied to the parsed value.
Validate function. Must return true for the value to be accepted.
Functions
@spec parse(t(), data :: any()) :: {:ok, result :: any()} | {:error, [Mold.Error.t(), ...]}
Parses data according to type and returns {:ok, value} or {:error, [%Mold.Error{}]}.
See t/0 for all accepted type forms.
iex> Mold.parse({:string, nilable: true, trim: true}, " ")
{:ok, nil}
iex> Mold.parse(:boolean, "false")
{:ok, false}
Parses data according to type or raises Mold.Error.t/0 on failure.
iex> Mold.parse!(%{name: :string}, %{"name" => "Bob"})
%{name: "Bob"}Multiple errors are combined into a single exception:
Mold.parse!(%{name: :string, age: :integer}, %{"name" => nil, "age" => "abc"})
#=> ** (Mold.Error) Unable to parse data
#=>
#=> 2 errors:
#=> 1. :unexpected_nil at ["name"] (value: nil)
#=> 2. :invalid_format at ["age"] (value: "abc")