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
andnew!
, 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
"""