TypedStructNimbleOptions - TypedStruct plugin for validation & documentation with NimbleOptions

TypedStructNimbleOptions: GitHub | Hex | Docs

I’ve cycled through many typing and type validation libraries for structs; I sometimes see multiple used in a single application projects.

Outside of Ecto contexts* I keep coming back to TypedStruct for defining my structures as a simple sugar on top of defstruct. TypedStruct is just declarative and does not get us any data validation. Also, the struct documentation (most importantly: field documentation) has to live outside the field definition, and it’s easy for it to go out of sync as the fields change.

Meanwhile, for validation of user-supplied arguments I like to use NimbleOptions to make sure everything is in the right shape before I work on it, and to get automatic documentation.

Since TypedStruct has plugin support, I figured I can mash the two together, with some very simple code to reduce boilerplate in common cases. Both TypedStruct and NimbleOptions are small and simple libraries, and keeping with that TypedStructNimbleOptions is a simple glue.

Some of the features include:

  • Basic types given in the field definitions are translated into NimbleOptions’ type, e.g. atom():atom, [pos_integer]{:list, :pos_integer}, String.t():string

    • This can be trivially overwritten with user-supplied NimbleOptions type
    • If a type cannot be automatically derived by TypedStructNimbleOptions, it’s assumed to be :any and a (suppressible) compilation warning is generated
  • Fields’ documentation gets added automatically to the @moduledoc and will be visible in the generated ExDocs documentation

    • This behaviour can also be disabled, per-struct or globally
  • Constructor functions are created, (by default) new and new!, that validate the input with NimbleOptions before creating the struct.

Example

defmodule Person do
  @moduledoc "A struct representing a person."
  @moduledoc since: "0.1.0"

  use TypedStruct

  typedstruct do
    plugin TypedStructNimbleOptions

    field :name, String.t(), enforce: true, doc: "The name."
    field :age, non_neg_integer(), doc: "The age."
    field :happy?, boolean(), default: true
    field :attrs, %{optional(atom()) => String.t()}
  end
end

# `new/1` returns {:ok, value} | {:error, reason}
iex> Person.new(name: "George", age: 31)
{:ok, %Person{name: "George", age: 31, happy?: true, attrs: nil}}

# `new!/1` raises on error
iex> Person.new!(name: "George", age: 31, attrs: %{phone: 123})
** (NimbleOptions.ValidationError) invalid map in :attrs option: invalid value for map key :phone: expected string, got: 123

# `field_docs/0-1` returns the fields' documentation
iex> Person.field_docs()
"""
* `:name` (`t:String.t/0`) - Required. The name.\n
* `:age` (`t:non_neg_integer/0`) - The age.\n
* `:happy?` (`t:boolean/0`) - The default value is `true`.\n
* `:attrs` (map of `t:atom/0` keys and `t:String.t/0` values)\n
"""
2 Likes