Domo
A library to validate values of nested structs with their type spec t() and associated precondition functions
Install / Use
/learn @IvanRublev/DomoREADME
Domo
Mix.install([:domo], force: true)
About
<!-- Documentation0 -->A library to validate values of nested structs with their type spec t()
and associated precondition functions.
Example apps
🔗 JSON parsing and validation example
🔗 Commanded + Domo combo used in Event Sourcing and CQRS app
🔗 Ecto + Domo combo in example_avialia app
🔗 TypedStruct + Domo combo in example_typed_integrations app
<!-- Documentation1 -->Description
The library adds constructor, validation, and reflection functions to a struct module. When called, constructor and validation functions guarantee the following:
- A struct or a group of nested structs conforms to their
t()types. - The struct's data consistently follows the business rules given
by type-associated precondition functions registered via
precondmacro.
If the conditions described above are not met, the constructor and validation functions return an error.
The business rule expressed via the precondition function is type-associated, which affects all structs referencing the appropriate type and using Domo.
In terms of Domain Driven Design, types and associated precondition functions define the invariants relating structs to each other.
<!-- Documentation1 -->
use Domo{: .info}When you
use Domo, theDomomodule will define the following functions:
newandnew!to create a valid structensure_typeandensure_type!to validate the existing struct- reflection functions
required_fields,typed_fields, and__t__See the Callbacks section for more details.
Tour
<p align="center" class="hidden"> <a href="https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2FIvanRublev%2FDomo%2Fblob%2Fmaster%2FREADME.md"> <img src="https://livebook.dev/badge/v1/blue.svg" alt="Run in Livebook" /> </a> </p>Let's say that we have a LineItem and PurchaseOrder structs with relating
invariant that is the sum of line item amounts should be less then order's
approved limit. That can be expressed like the following:
defmodule LineItem do
use Domo
defstruct amount: 0
@type t :: %__MODULE__{amount: non_neg_integer()}
end
defmodule PurchaseOrder do
use Domo
defstruct id: 1000, approved_limit: 200, items: []
@type id :: non_neg_integer()
precond(id: &(1000 <= &1 and &1 <= 5000))
@type t :: %__MODULE__{
id: id(),
approved_limit: pos_integer(),
items: [LineItem.t()]
}
precond(t: &validate_invariants/1)
defp validate_invariants(po) do
amounts = po.items |> Enum.map(& &1.amount) |> Enum.sum()
if amounts <= po.approved_limit do
:ok
else
{:error, "Sum of line item amounts (#{amounts}) should be <= to approved limit (#{po.approved_limit})."}
end
end
end
Then PurchaseOrder struct can be constructed consistently with functions generated by Domo like the following:
PurchaseOrder.new()
{:ok, %PurchaseOrder{approved_limit: 200, id: 1000, items: []}}
The constructor function takes any Enumerable as the input value:
{:ok, po} = PurchaseOrder.new(%{approved_limit: 250})
{:ok, %PurchaseOrder{approved_limit: 250, id: 1000, items: []}}
It returns the descriptive keyword list if there is an error in input arguments. And it validates nested structs automatically:
PurchaseOrder.new(id: 500, items: [%LineItem{amount: -5}])
{:error,
[
items: "Invalid value [%LineItem{amount: -5}] for field :items of %PurchaseOrder{}.
Expected the value matching the [%LineItem{}] type.
Underlying errors:
- The element at index 0 has value %LineItem{amount: -5} that is invalid.
- Value of field :amount is invalid due to Invalid value -5 for field :amount
of %LineItem{}. Expected the value matching the non_neg_integer() type.",
id: "Invalid value 500 for field :id of %PurchaseOrder{}. Expected the
value matching the non_neg_integer() type. And a true value from
the precondition function \"&(1000 <= &1 and &1 <= 5000)\"
defined for PurchaseOrder.id() type."
]}
The returned errors are verbose and are intended for debugging purposes. See the Error messages for a user section below for more options.
The manually updated struct can be validated like the following:
po
|> Map.put(:items, [LineItem.new!(amount: 150)])
|> PurchaseOrder.ensure_type()
{:ok, %PurchaseOrder{approved_limit: 200, id: 1000, items: [%LineItem{amount: 150}]}}
Domo returns the error if the precondition function validating the t() type
as a whole fails:
updated_po = %{po | items: [LineItem.new!(amount: 180), LineItem.new!(amount: 100)]}
PurchaseOrder.ensure_type(updated_po)
{:error, [t: "Sum of line item amounts should be <= to approved limit"]}
Domo supports sum types for struct fields, for example:
defmodule FruitBasket do
use Domo
defstruct fruits: []
@type t() :: %__MODULE__{fruits: [String.t() | :banana]}
end
FruitBasket.new(fruits: [:banana, "Maracuja"])
{:ok, %FruitBasket{fruits: [:banana, "Maracuja"]}}
{:error, [fruits: message]} = FruitBasket.new(fruits: [:banana, "Maracuja", nil])
IO.puts(message)
Invalid value [:banana, "Maracuja", nil] for field :fruits of %FruitBasket{}. Expected the value matching the [<<_::_*8>> | :banana] type.
Underlying errors:
- The element at index 2 has value nil that is invalid.
- Expected the value matching the <<_::_*8>> type.
- Expected the value matching the :banana type.
Getting the list of the required fields of the struct that have type other
then nil or any is like that:
PurchaseOrder.required_fields()
[:approved_limit, :id, :items]
For debug purposes you can get the original type spec t() for the struct.
That might be useful when the struct's type spec is generated by some other library such as TypedEctoSchema.
Do it like the following:
PurchaseOrder.__t__()
%PurchaseOrder{
id: integer() | nil
...
}
See the Callbacks section for more details about functions added to the struct.
Error messages for a user
It's possible to attach error messages to types with the precond macro to display
them later to the user. To filter such kinds of messages, pass
the maybe_filter_precond_errors: true option to Domo generated functions like that:
defmodule Book do
use Domo
defstruct [:title, :pages]
@type title :: String.t()
precond title: &(if String.length(&1) > 1, do: :ok, else: {:error, "Book title is required."})
@type pages :: pos_integer()
precond pages: &(if &1 > 2, do: :ok, else: {:error, "Book should have more then 3 pages. Given (#{&1})."})
@type t :: %__MODULE__{title: nil | title(), pages: nil | pages()}
end
defmodule Shelf do
use Domo
defstruct books: []
@type t :: %__MODULE__{books: [Book.t()]}
end
defmodule PublicLibrary do
use Domo
defstruct shelves: []
@type t :: %__MODULE__{shelves: [Shelf.t()]}
end
PublicLibrary.new(
%{shelves: [struct!(Shelf, %{books: [struct!(Book, %{title: "", pages: 1})]})]},
maybe_filter_precond_errors: true
)
{:error,
[
shelves: [
"Book title is required.",
"Book should have more then 3 pages. Given (1)."
]
]}
That output contains only a flattened list of precondition error messages from the deeply nested structure.
<!-- Documentation2 -->Custom constructor function
Sometimes a default value for the struct's field must be generated during the construction.
To reuse the new(!)/1 function's name and keep the generated value validated,
instruct Domo to use another name for its constructor with gen_constructor_name option,
like the following:
defmodule Foo do
use Domo, skip_defaults: true, gen_constructor_name: :_new
defstruct [:id, :token]
@type id :: non_neg_integer()
@type token :: String.t()
precond token: &byte_size(&1) == 8
@type t :: %__MODULE__{id: id(), token: token()}
def new(id) do
_new(id: id, token: random_string(8))
end
def new!(id) do
_new!(id: id, token: random_string(8))
end
defp random_string(length),
do: :crypto.strong_rand_bytes(length) |> Base.encode64() |> binary_part(0, length)
end
Foo.new!(15245)
%Foo{id: 15245, token: "e8K9wP0e"}
Compile-time and Run-time validations
At the project's compile-time, Domo performs the following checks:
-
It automatically validates that the default values given with
defstruct/1conform to the struct's type and fulfil preconditions (can be disabled, see__using__/1for more details). You can turn off this behaviour by specifyingskip_defaultsoption; see Configuration section for details. -
It ensures that the struct using Domo built with
new(!)/1function to be a default argument for a function or a default value for a struct's field matches its type and preconditions.
At run-time, Domo validates structs matching their t() types.
Domo compiles TypeEnsurer module from struct's t() type to do all kinds
of validations. There is a generated functi
Related Skills
ai-cmo
Collection of my Agent Skills and books.
next
A beautifully designed, floating Pomodoro timer that respects your workspace.
product-manager-skills
38PM skill for Claude Code, Codex, Cursor, and Windsurf: diagnose SaaS metrics, critique PRDs, plan roadmaps, run discovery, and coach PM career transitions.
devplan-mcp-server
3MCP server for generating development plans, project roadmaps, and task breakdowns for Claude Code. Turn project ideas into paint-by-numbers implementation plans.
