SkillAgentSearch skills...

Domo

A library to validate values of nested structs with their type spec t() and associated precondition functions

Install / Use

/learn @IvanRublev/Domo
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Domo

Mix.install([:domo], force: true)

About

|Elixir CI|Method TDD|hex.pm version| |-|-|-|

<!-- Documentation0 -->

A library to validate values of nested structs with their type spec t() and associated precondition functions.

<!-- Documentation0 -->

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 precond macro.

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.

use Domo {: .info}

When you use Domo, the Domo module will define the following functions:

  • new and new! to create a valid struct
  • ensure_type and ensure_type! to validate the existing struct
  • reflection functions required_fields, typed_fields, and __t__

See the Callbacks section for more details.

<!-- Documentation1 -->

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/1 conform to the struct's type and fulfil preconditions (can be disabled, see __using__/1 for more details). You can turn off this behaviour by specifying skip_defaults option; see Configuration section for details.

  • It ensures that the struct using Domo built with new(!)/1 function 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

View on GitHub
GitHub Stars215
CategoryProduct
Updated3mo ago
Forks13

Languages

Elixir

Security Score

97/100

Audited on Dec 16, 2025

No findings