diff --git a/README.md b/README.md index 96ff1f4..5b18073 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,46 @@ [![Document](https://img.shields.io/badge/document-gray)](https://hexdocs.pm/typed_structor) [![Plugin guides](https://img.shields.io/badge/plugin_guides-indianred?label=%F0%9F%94%A5&labelColor=snow)](https://hexdocs.pm/typed_structor/introduction.html) - +Define your structs and types in one place. TypedStructor generates `defstruct`, type specs, and `@enforce_keys` while keeping your code clean and explicit. + +## Why TypedStructor? + +**Without TypedStructor**, you write everything twice: -`TypedStructor` is a library for defining structs with types effortlessly. -(This library is a rewritten version of [TypedStruct](https://github.com/ejpcmac/typed_struct) because it is no longer actively maintained.) +```elixir +defmodule User do + @enforce_keys [:id] + defstruct [:id, :name, :age] + + @type t() :: %__MODULE__{ + id: pos_integer(), + name: String.t() | nil, + age: non_neg_integer() | nil + } +end +``` + +**With TypedStructor**, you write it once: + +```elixir +defmodule User do + use TypedStructor + + typed_structor do + field :id, pos_integer(), enforce: true + field :name, String.t() + field :age, non_neg_integer() + end +end +``` + +Same result, half the boilerplate. Your struct definition and type spec stay in sync automatically. + + ## Installation -Add `:typed_structor` to the list of dependencies in `mix.exs`: +Add `:typed_structor` to your dependencies in `mix.exs`: ```elixir def deps do @@ -22,159 +54,182 @@ def deps do end ``` -Add `:typed_structor` to your `.formatter.exs` file - -```elixir -[ - # import the formatter rules from `:typed_structor` - import_deps: [..., :typed_structor], - inputs: [...] -] -``` - -## Usage +> #### Formatter Setup {: .tip} +> +> Add `:typed_structor` to your `.formatter.exs` for proper indentation: +> +> ```elixir +> [ +> import_deps: [..., :typed_structor], +> inputs: [...] +> ] +> ``` -### General usage +## Getting Started -To define a struct with types, use `TypedStructor`, -and then define fields under the `TypedStructor.typed_structor/2` macro, -using the `TypedStructor.field/3` macro to define each field. +Use `typed_structor` blocks to define fields with their types: ```elixir defmodule User do - # use TypedStructor to import the `typed_structor` macro use TypedStructor typed_structor do - # Define each field with the `field` macro. field :id, pos_integer() - - # set a default value - field :name, String.t(), default: "Unknown" - - # enforce a field - field :age, non_neg_integer(), enforce: true + field :name, String.t() end end ``` -This is equivalent to: -```elixir -defmodule User do - defstruct [:id, :name, :age] - @type t() :: %__MODULE__{ - id: pos_integer() | nil, - # Note: The 'name' can not be nil, for it has a default value. - name: String.t(), - age: non_neg_integer() - } +This generates a struct and type where fields are nullable by default (`pos_integer() | nil`). + +### Enforcing Required Fields + +Make fields non-nullable by enforcing them: + +```elixir +typed_structor do + field :id, pos_integer(), enforce: true # Required, never nil + field :name, String.t() # Optional, can be nil end ``` -Check `TypedStructor.typed_structor/2` and `TypedStructor.field/3` for more information. -### Options +### Providing Defaults -You can also generate an `opaque` type for the struct, -even changing the type name: +Fields with defaults don't need to be nullable since they always have a value: ```elixir -defmodule User do - use TypedStructor - - typed_structor type_kind: :opaque, type_name: :profile do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() - end +typed_structor do + field :id, pos_integer(), enforce: true + field :name, String.t(), default: "Unknown" # String.t() (not nullable) + field :age, non_neg_integer() # non_neg_integer() | nil end ``` -This is equivalent to: + +### Controlling Nullability + +You can explicitly control whether fields accept `nil`: + ```elixir -defmodule User do - use TypedStructor +typed_structor do + field :id, integer(), null: false # Never nil + field :name, String.t(), null: true # Always nullable +end +``` - defstruct [:id, :name, :age] +Or set defaults for all fields in a block: - @opaque profile() :: %__MODULE__{ - id: pos_integer() | nil, - name: String.t() | nil, - age: non_neg_integer() | nil - } +```elixir +typed_structor null: false do + field :id, integer() # Not nullable by default + field :email, String.t() # Not nullable by default + field :phone, String.t(), null: true # Override for this field end ``` -Type parameters also can be defined: +The interaction between `enforce`, `default`, and `null` follows this logic: + +| `:default` | `:enforce` | `:null` | Type includes `nil`? | +|------------|------------|---------|----------------------| +| `unset` | `false` | `true` | yes | +| `unset` | `false` | `false` | no | +| `set` | - | - | no | +| - | `true` | - | no | + +This is particularly useful when modeling database records where some fields can be `nil`: + ```elixir -defmodule User do +defmodule DatabaseRecord do use TypedStructor typed_structor do - parameter :id - parameter :name + # These fields can be nil when loaded from DB + field :name, String.t() + field :description, String.t() - field :id, id - field :name, name - field :age, non_neg_integer() + # These fields cannot be nil (e.g., primary keys, timestamps) + field :id, integer(), null: false + field :inserted_at, DateTime.t(), null: false + field :updated_at, DateTime.t(), null: false end end ``` -becomes: + +## Options + +TypedStructor provides several options to customize your type definitions: + +### Opaque Types + +Use `type_kind: :opaque` to hide implementation details: + ```elixir -defmodule User do - @type t(id, name) :: %__MODULE__{ - id: id | nil, - name: name | nil, - age: non_neg_integer() | nil - } +typed_structor type_kind: :opaque do + field :id, pos_integer() + field :secret, String.t() +end - defstruct [:id, :name, :age] +# Generates: @opaque t() :: %__MODULE__{...} +``` + +### Custom Type Names + +Override the default `t()` type name with `type_name`: + +```elixir +typed_structor type_name: :user_data do + field :id, pos_integer() + field :name, String.t() end + +# Generates: @type user_data() :: %__MODULE__{...} ``` -If you prefer to define a struct in a submodule, you can use -the `module` option with `TypedStructor`. This allows you to -encapsulate the struct definition within a specific submodule context. +### Type Parameters + +Create generic types with `parameter/1`: -Consider this example: ```elixir -defmodule User do - use TypedStructor +typed_structor do + parameter :value_type + parameter :error_type - # `%User.Profile{}` is generated - typed_structor module: Profile do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() - end + field :value, value_type + field :error, error_type end + +# Generates: @type t(value_type, error_type) :: %__MODULE__{...} ``` -When defining a struct in a submodule, the `typed_structor` block -functions similarly to a `defmodule` block. Therefore, -the previous example can be alternatively written as: + +## Common Patterns + +### Nested Structs + +Define structs in submodules: + ```elixir defmodule User do - defmodule Profile do - use TypedStructor + use TypedStructor - typed_structor do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() - end + typed_structor module: Profile do + field :email, String.t(), enforce: true + field :bio, String.t() end end + +# Creates User.Profile with its own struct and type ``` -Furthermore, the `typed_structor` block allows you to -define functions, derive protocols, and more, just -as you would within a `defmodule` block. Here's a example: +### Integration with Other Tools + +Skip struct generation to use with Ecto or other schema tools: + ```elixir defmodule User do use TypedStructor typed_structor module: Profile, define_struct: false do @derive {Jason.Encoder, only: [:email]} - field :email, String.t() + field :email, String.t(), enforce: true use Ecto.Schema @primary_key false @@ -193,25 +248,12 @@ defmodule User do end end ``` -Now, you can interact with these structures: -```elixir -iex> User.Profile.__struct__() -%User.Profile{__meta__: #Ecto.Schema.Metadata<:built, "users">, email: nil} -iex> Jason.encode!(%User.Profile{}) -"{\"email\":null}" -iex> User.Profile.changeset(%User.Profile{}, %{"email" => "my@email.com"}) -#Ecto.Changeset< - action: nil, - changes: %{email: "my@email.com"}, - errors: [], - data: #User.Profile<>, - valid?: true -> -``` -## Define an Exception -In Elixir, an exception is defined as a struct that includes a special field named `__exception__`. -To define an exception, use the `defexception` definer within the `typed_structor` block. +## Advanced Features + +### Exceptions + +Define typed exceptions with automatic `__exception__` handling: ```elixir defmodule HTTPException do @@ -219,81 +261,78 @@ defmodule HTTPException do typed_structor definer: :defexception, enforce: true do field :status, non_neg_integer() + field :message, String.t() end @impl Exception - def message(%__MODULE__{status: status}) do - "HTTP status #{status}" + def message(%__MODULE__{status: status, message: msg}) do + "HTTP #{status}: #{msg}" end end ``` -## Define records related macros +### Records -In Elixir, you can use the Record module to define and work with Erlang records, -making interoperability between Elixir and Erlang more seamless. +Create Erlang-compatible records for interoperability: ```elixir -defmodule TypedStructor.User do +defmodule UserRecord do use TypedStructor - typed_structor definer: :defrecord, record_name: :user, record_tag: User, enforce: true do - field :name, String.t() - field :age, pos_integer() + typed_structor definer: :defrecord, record_name: :user do + field :name, String.t(), enforce: true + field :age, pos_integer(), enforce: true end end ``` -## Documentation - -To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: -```elixir -typed_structor do - @typedoc "A typed user" +## Plugins - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() -end -``` -You can also document submodules this way: +Extend TypedStructor's behavior with plugins. They run during compilation to add functionality: ```elixir -typedstructor module: Profile do - @moduledoc "A user profile struct" - @typedoc "A typed user profile" +defmodule User do + use TypedStructor - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() + typed_structor do + plugin Guides.Plugins.Accessible # Adds Access behavior + + field :id, pos_integer() + field :name, String.t() + end end + +user = %User{id: 1, name: "Phil"} +get_in(user, [:name]) # => "Phil" ``` -## Plugins +> #### Plugin Guides {: .tip} +> +> Check out the [Plugin Guides](guides/plugins/introduction.md) to learn how to create your own plugins. +> All examples include copy-paste-ready code. + +## Documentation -`TypedStructor` offers a plugin system to enhance functionality. -For details on creating a plugin, refer to the `TypedStructor.Plugin` module. +Add `@moduledoc` at the module level, and `@typedoc` inside the block: -Here is a example of `Guides.Plugins.Accessible` plugin to define `Access` behavior for the struct. ```elixir defmodule User do + @moduledoc "User management structures" use TypedStructor typed_structor do - plugin Guides.Plugins.Accessible + @typedoc "A user with authentication details" field :id, pos_integer() field :name, String.t() - field :age, non_neg_integer() end end - -user = %User{id: 1, name: "Phil", age: 20} -get_in(user, [:name]) # => "Phil" ``` -> #### Plugins guides {: .tip} -> -> Here are some [Plugin Guides](guides/plugins/introduction.md) -> for creating your own plugins. Please check them out -> and feel free to copy-paste the code. + + +## Learn More + +- **API Reference**: Check `TypedStructor.typed_structor/2` and `TypedStructor.field/3` for all options +- **Plugin System**: See `TypedStructor.Plugin` for creating custom plugins +- **Guides**: Visit [hexdocs.pm/typed_structor](https://hexdocs.pm/typed_structor) for detailed guides diff --git a/guides/plugins/doc_fields.md b/guides/plugins/doc_fields.md index 9b4c173..c1f4917 100644 --- a/guides/plugins/doc_fields.md +++ b/guides/plugins/doc_fields.md @@ -119,13 +119,22 @@ defmodule Guides.Plugins.DocFields do type = Keyword.fetch!(field, :type) enforce = Keyword.get(definition.options, :enforce, false) + null = Keyword.get(definition.options, :null, true) type = - if Keyword.get(field, :enforce, enforce) or Keyword.has_key?(field, :default) do - Macro.to_string(type) - else - # escape `|` - "#{Macro.to_string(type)} \\| nil" + cond do + # If field has default or is enforced, type stays as is + Keyword.has_key?(field, :default) or Keyword.get(field, :enforce, enforce) -> + Macro.to_string(type) + + # If null option is true (default), add nil to type + Keyword.get(field, :null, null) -> + # escape `|` + "#{Macro.to_string(type)} \\| nil" + + # If null is false, type stays as is + true -> + Macro.to_string(type) end doc = Keyword.get(field, :doc, "*not documented*") diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 2328596..d7a6079 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -2,7 +2,7 @@ defmodule TypedStructor do @external_resource "README.md" @moduledoc "README.md" |> File.read!() - |> String.split("", parts: 2) + |> String.split("", parts: 3) |> Enum.fetch!(1) @built_in_definers [ @@ -27,6 +27,7 @@ defmodule TypedStructor do * `:module` - if provided, a new submodule will be created with the struct. * `:enforce` - if `true`, the struct will enforce the keys, see `field/3` options for more information. + * `:null` - if `true` (default), fields without a default value and not enforced will have `nil` added to their type. If `false`, prevents `nil` from being added. * `:definer` - the definer module to use to define the struct, record or exception. Defaults to `:defstruct`. It also accepts a macro that receives the definition struct and returns the AST. See definer section below. * `:type_kind` - the kind of type to use for the struct. Defaults to `type`, can be `opaque` or `typep`. * `:type_name` - the name of the type to use for the struct. Defaults to `t`. @@ -237,15 +238,18 @@ defmodule TypedStructor do * `:default` - sets the default value for the field * `:enforce` - if set to `true`, enforces the field, and makes its type non-nullable if `:default` is not set - - > ### How `:default` and `:enforce` affect `type` and `@enforce_keys` {: .tip} - > - > | **`:default`** | **`:enforce`** | **`type`** | **`@enforce_keys`** | - > | -------------- | -------------- | ----------------- | ------------------- | - > | `set` | `true` | `t()` | `excluded` | - > | `set` | `false` | `t()` | `excluded` | - > | `unset` | `true` | `t()` | **`included`** | - > | `unset` | `false` | **`t() \\| nil`** | `excluded` | + * `:null` - when set to `true` (the default), allows `nil` to be added to the + field type when `:default` is not set and `:enforce` is not set; when set + to `false`, prevents `nil` from being added in that case + + > ### How `:default`, `:enforce` and `:null` affect `type` and `@enforce_keys` {: .tip} + + > | **`:default`** | **`:enforce`** | **`:null`** | **`type`** | **`@enforce_keys`** | + > | -------------- | -------------- | ----------- | ----------------- | ------------------- | + > | `set` | - | - | `t()` | `excluded` | + > | `unset` | `true` | - | `t()` | **`included`** | + > | `unset` | `false` | `true` | **`t() \\| nil`** | `excluded` | + > | `unset` | `false` | `false` | `t()` | `excluded` | """ defmacro field(name, type, options \\ []) do options = Keyword.merge(options, name: name, type: Macro.escape(type)) diff --git a/lib/typed_structor/definer/utils.ex b/lib/typed_structor/definer/utils.ex index 3fc8dbc..235fec0 100644 --- a/lib/typed_structor/definer/utils.ex +++ b/lib/typed_structor/definer/utils.ex @@ -50,11 +50,19 @@ defmodule TypedStructor.Definer.Utils do name = Keyword.fetch!(field, :name) type = Keyword.fetch!(field, :type) - if get_keyword_value(field, :enforce, definition.options, false) or - Keyword.has_key?(field, :default) do - {name, type} - else - {name, quote(do: unquote(type) | nil)} + has_default = Keyword.has_key?(field, :default) + enforce = get_keyword_value(field, :enforce, definition.options, false) + null = get_keyword_value(field, :null, definition.options, true) + + cond do + has_default or enforce -> + {name, type} + + null -> + {name, quote(do: unquote(type) | nil)} + + true -> + {name, type} end end) diff --git a/test/null_option_test.exs b/test/null_option_test.exs new file mode 100644 index 0000000..a8e87a1 --- /dev/null +++ b/test/null_option_test.exs @@ -0,0 +1,182 @@ +defmodule TypedStructor.NullOptionTest do + use TypedStructor.TestCase, async: true + + describe "field-level null option" do + @tag :tmp_dir + test "null: false prevents nil from being added to type", ctx do + expected_types = + with_tmpmodule TestStruct, ctx do + @type t() :: %__MODULE__{ + id: integer(), + name: String.t() | nil, + status: String.t(), + email: String.t(), + description: String.t() | nil, + code: String.t() + } + defstruct id: nil, name: nil, status: "active", email: nil, description: nil, code: nil + after + fetch_types!(TestStruct) + end + + generated_types = + with_tmpmodule TestStruct, ctx do + use TypedStructor + + typed_structor do + field :id, integer(), null: false + field :name, String.t(), null: true + field :status, String.t(), default: "active", null: true + field :email, String.t(), enforce: true, null: true + field :description, String.t() + field :code, String.t(), enforce: false, null: false + end + after + fetch_types!(TestStruct) + end + + assert_type expected_types, generated_types + end + + @tag :tmp_dir + test "struct fields default to nil regardless of null option", ctx do + with_tmpmodule TestStruct, ctx do + use TypedStructor + + typed_structor do + field :id, integer(), null: false + field :name, String.t() + field :email, String.t(), enforce: true + end + after + # Struct fields can be nil at runtime regardless of null option in the type spec + struct = struct(TestStruct, email: "test@example.com") + assert struct.id == nil + assert struct.name == nil + assert struct.email == "test@example.com" + end + end + end + + describe "module-level null option" do + @tag :tmp_dir + test "module-level null: false applies to all fields without explicit null", ctx do + expected_types = + with_tmpmodule ModuleLevelNull, ctx do + @type t() :: %__MODULE__{ + id: integer(), + name: String.t() | nil, + status: String.t(), + email: String.t(), + code: String.t() + } + defstruct id: nil, name: nil, status: "active", email: nil, code: nil + after + fetch_types!(ModuleLevelNull) + end + + generated_types = + with_tmpmodule ModuleLevelNull, ctx do + use TypedStructor + + typed_structor null: false do + field :id, integer() + field :name, String.t(), null: true + field :status, String.t(), default: "active" + field :email, String.t(), enforce: true + field :code, String.t() + end + after + fetch_types!(ModuleLevelNull) + end + + assert_type expected_types, generated_types + end + end + + describe "database loading use case" do + @tag :tmp_dir + test "non-nullable fields for required database columns", ctx do + expected_types = + with_tmpmodule DatabaseLoadingExample, ctx do + @type t() :: %__MODULE__{ + id: integer(), + name: String.t() | nil, + email: String.t() | nil, + phone: String.t() | nil, + inserted_at: DateTime.t(), + updated_at: DateTime.t(), + preferences: map() | nil, + metadata: map() | nil + } + defstruct [ + :id, + :name, + :email, + :phone, + :inserted_at, + :updated_at, + :preferences, + :metadata + ] + after + fetch_types!(DatabaseLoadingExample) + end + + generated_types = + with_tmpmodule DatabaseLoadingExample, ctx do + use TypedStructor + + typed_structor do + field :id, integer(), null: false + field :name, String.t() + field :email, String.t() + field :phone, String.t() + field :inserted_at, DateTime.t(), null: false + field :updated_at, DateTime.t(), null: false + field :preferences, map() + field :metadata, map() + end + after + fetch_types!(DatabaseLoadingExample) + end + + assert_type expected_types, generated_types + end + end + + describe "interaction with other options" do + @tag :tmp_dir + test "default and enforce take precedence over null option", ctx do + expected_types = + with_tmpmodule InteractionTest, ctx do + @type t() :: %__MODULE__{ + status: String.t(), + email: String.t(), + code: String.t() + } + defstruct status: "active", email: nil, code: nil + after + fetch_types!(InteractionTest) + end + + generated_types = + with_tmpmodule InteractionTest, ctx do + use TypedStructor + + typed_structor do + # default takes precedence over null + field :status, String.t(), default: "active", null: true + # enforce takes precedence over null + field :email, String.t(), enforce: true, null: true + # enforce: false with null: false + field :code, String.t(), enforce: false, null: false + end + after + fetch_types!(InteractionTest) + end + + assert_type expected_types, generated_types + end + end +end