README.md

# TypedStructNimbleOptions

[![CI](https://github.com/kzemek/typed_struct_nimble_options/actions/workflows/elixir.yml/badge.svg)](https://github.com/kzemek/typed_struct_nimble_options/actions/workflows/elixir.yml)
[![Module Version](https://img.shields.io/hexpm/v/typed_struct_nimble_options.svg)](https://hex.pm/packages/typed_struct_nimble_options)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/typed_struct_nimble_options/)
[![License](https://img.shields.io/hexpm/l/typed_struct_nimble_options.svg)](https://github.com/kzemek/typed_struct_nimble_options/blob/master/LICENSE)

**TypedStructNimbleOptions** is a plugin for [TypedStruct](https://hex.pm/packages/typed_struct) that allows to easily type, validate & document Elixir structs, all in one place and with little boilerplate.

It leverages [NimbleOptions](https://hex.pm/packages/nimble_options) for validation & documentation.
Each `field` of TypedStruct is a key in the generated NimbleOptions schema.
Many options on `field` are passed directly to NimbleOptions, but in most cases they're automatically derived from the type (and other TypedStruct options).

For example

```elixir
field :attrs, %{optional(atom()) => String.t()}, enforce: true, doc: "User attributes."
```

will generate and validate with the following NimbleOptions schema:

```elixir
attrs: [
  type: {:map, :atom, :string}, # automatically derived from type
  required: true, # due to enforce: true
  doc: "User attributes.",
  type_spec: quote(do: %{optional(atom()) => String.t()})
]
```

## Example

```elixir
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
"""
```

### Generated `@moduledoc`s

The available options will append themselves to the `@moduledoc`.
You can modify this behavior with `append_moduledoc_header` option.

For example, the ExDoc page for the `Person` module above would start with:

```markdown
# Person

A struct representing a person.

## Fields

- `:phone` (`String.t/0`)

- `:happy?` (`boolean/0`) - The default value is `true`.

- `:age` (`non_neg_integer/0`) - The age.

- `:name` (`String.t/0`) - Required. The name.
```

## Installation

The package can be installed by adding `typed_struct_nimble_options` to your list of dependencies in `mix.exs`:

```elixir
# mix.exs

def deps do
  [
    {:typed_struct_nimble_options, "~> 0.1.0"}
  ]
end
```

## Global settings

Settings can be specified in application config to apply to all of application's
TypedStructNimbleOptions structs by default.

For example, to disable defining the documentation function by default, you can add the following setting:

```elixir
# config/config.exs
config :my_otp_app, TypedStructNimbleOptions, docs: nil
```

Individually, the same settings can be set directly on `plugin TypedStructNimbleOptions` and they will override the default settings if given.

For example, the below docs setting will override the `config.exs` setting above:

```elixir
typedstruct do
  plugin TypedStructNimbleOptions, docs: :field_docs
end
```

### Supported settings

- `:ctor` - Name of the non-bang constructor function. `nil` disables constructor generation. The default value is `:new`.
- `:ctor!` - Name of the bang constructor function. `nil` disables bang constructor generation. The default value is `:new!`.
- `:docs` - Name of the functions that return the NimbleOptions docs for the struct. `nil` disables docs functions generation. The default value is `:field_docs`.
- `:otp_app` (atom/0) - Name of the OTP application using TypedStructNimbleOptions. This is used to fetch global options for the TypedStructNimbleOptions. Defaults to `Application.get_application(__CALLER__.module)`.
- `:append_moduledoc_header` - Whether to append the generated docs to the `@moduledoc`. The docs are appended with the header specified in this option. Docs are appended only if the header is not nil. The default value is `"\n## Fields"`.
- `:warn_unknown_types?` (boolean/0) - Whether to warn when an unknown type is encountered. The default value is `true`.

## Field options

All of the following options are passed to NimbleOptions as-is, with the exception of `validation_type` which is renamed to `type` as it's being passed down.

See https://hexdocs.pm/nimble_options/NimbleOptions.html#module-schema-options for more information on the supported options.

### Automatically derived

These options are derived from information given to TypedStruct.
Users can override the settings by specifying them manually.

- `:required` - set to true if the field is `enforced` and has no `default`.
- `:type_spec` - set to the field's type
- `:validation_type` (`type`) - many basic types supported by NimbleOptions are automatically derived from the field's type, for example `atom()` type will set `type: :atom` and `String.t()` will set `type: :string`.
  Some more complex types like maps or lists are also supported.

  If TypedStructNimbleOptions encounters a type it cannot derive, it will fall back to `:any` and generate a compilation warning.
  The warnings can be disabled on a struct or global level with `warn_unknown_types?: false`.

### Passed as-is if they're given

- `:default`
- `:keys`
- `:deprecated`
- `:doc`
- `:type_doc`
- `subsection`

## Nested structs

Nested structs in particular are not automagically validated.
You should either specify `validation_type: {:struct, MyNestedStruct}` manually for the struct field, or run a custom validator with `validation_type: {:custom, M, :f, []}`.

If the nested struct also uses TypedStructNimbleOptions, you can use a special `validation_type: {:nested_struct, MyNestedStruct, :new}` that will internally run `MyNestedStruct.new/1` with proper error handling:

```elixir
defmodule Profile do
  use TypedStruct

  typedstruct enforce: true do
    plugin TypedStructNimbleOptions
    field :name, String.t()
  end
end

defmodule User do
  use TypedStruct

  typedstruct enforce: true do
    plugin TypedStructNimbleOptions
    field :id, pos_integer()
    field :profile, Profile.t(), validation_type: {:nested_struct, Profile, :new}
  end
end

iex> User.new!(id: 1, profile: [name: "UserName"])
%User{id: 1, profile: %Profile{name: "UserName"}}

iex> User.new!(id: 2, profile: %Profile{name: "UserName"})
%User{id: 2, profile: %Profile{name: "UserName"}}

iex> User.new(id: 3, profile: [name: 404])
{:error,
  %NimbleOptions.ValidationError{
    key: :profile, value: [name: 404], keys_path: [],
    message: "invalid value for :profile option: invalid value for :name option: expected string, got: 404"}}
```

## Limitations

### Compilation-time schema

The NimbleOptions schema is prepared at the compilation time, so it cannot contain runtime elements such as `fn` definitions.

## Automatically derived types

Non-parametrized types can be given with or without parentheses, e.g. `map()` is equivalent to `map`.

| Elixir type                                    | NimbleOption type                    |
| ---------------------------------------------- | ------------------------------------ |
| `map()`, `%{}`                                 | `{:map, :any, :any}`                 |
| `%{optional(key) => value}`, `%{key => value}` | `{:map, derive(key), derive(value)}` |
| `list()`                                       | `{:list, :any}`                      |
| `list(subtype)`, `[subtype]`                   | `{:list, derive(subtype)}`           |
| `{typea, ...}`                                 | `{:tuple, [derive(typea), ...]}`     |
| `atom()`                                       | `:atom`                              |
| `String.t()`                                   | `:string`                            |
| `boolean()`                                    | `:boolean`                           |
| `integer()`                                    | `:integer`                           |
| `non_neg_integer()`                            | `:non_neg_integer`                   |
| `pos_integer()`                                | `:pos_integer`                       |
| `float()`                                      | `:float`                             |
| `timeout()`                                    | `:timeout`                           |
| `pid()`                                        | `:pid`                               |
| `reference()`                                  | `:reference`                         |
| `nil`                                          | `nil`                                |
| `mfa()`                                        | `:mfa`                               |
| `any()`, `term()`                              | `:any`                               |