From 17b59233644ae6d7785841870dd05efc09460490 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Sun, 21 Dec 2025 21:36:08 +0900 Subject: [PATCH 1/5] feat: add null option for field types --- README.md | 61 +++++++++ guides/plugins/doc_fields.md | 19 ++- lib/typed_structor.ex | 25 ++-- lib/typed_structor/definer/utils.ex | 18 ++- test/null_option_test.exs | 187 ++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 test/null_option_test.exs diff --git a/README.md b/README.md index 96ff1f4..bff3f06 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ defmodule User do end end ``` + This is equivalent to: + ```elixir defmodule User do defstruct [:id, :name, :age] @@ -70,8 +72,52 @@ defmodule User do } end ``` + Check `TypedStructor.typed_structor/2` and `TypedStructor.field/3` for more information. +### The `null` option + +By default, fields without a default value and not enforced have their type set to `type | nil`. You can control this behavior with the `null` option: + +```elixir +defmodule User do + use TypedStructor + + typed_structor do + # This field will have type `integer()` (not nullable) + field :id, integer(), null: false + + # This field will have type `String.t() | nil` (nullable, default behavior) + field :name, String.t(), null: true + + # Fields with defaults are never nullable, regardless of `null` option + field :status, String.t(), default: "active", null: true # type is `String.t()` + + # Enforced fields are never nullable, regardless of `null` option + field :email, String.t(), enforce: true, null: true # type is `String.t()` + end +end +``` + +The `null` option is particularly useful when loading data from external sources like databases: + +```elixir +defmodule DatabaseRecord do + use TypedStructor + + typed_structor do + # These fields must exist in the struct but can be nil when loaded from DB + field :name, String.t() + field :description, String.t() + + # These fields must exist and 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 +``` + ### Options You can also generate an `opaque` type for the struct, @@ -88,7 +134,9 @@ defmodule User do end end ``` + This is equivalent to: + ```elixir defmodule User do use TypedStructor @@ -104,6 +152,7 @@ end ``` Type parameters also can be defined: + ```elixir defmodule User do use TypedStructor @@ -118,7 +167,9 @@ defmodule User do end end ``` + becomes: + ```elixir defmodule User do @type t(id, name) :: %__MODULE__{ @@ -136,6 +187,7 @@ the `module` option with `TypedStructor`. This allows you to encapsulate the struct definition within a specific submodule context. Consider this example: + ```elixir defmodule User do use TypedStructor @@ -148,9 +200,11 @@ defmodule User do end end ``` + 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: + ```elixir defmodule User do defmodule Profile do @@ -168,6 +222,7 @@ end 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: + ```elixir defmodule User do use TypedStructor @@ -193,7 +248,9 @@ 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} @@ -208,6 +265,7 @@ iex> User.Profile.changeset(%User.Profile{}, %{"email" => "my@email.com"}) valid?: true > ``` + ## Define an Exception In Elixir, an exception is defined as a struct that includes a special field named `__exception__`. @@ -243,6 +301,7 @@ defmodule TypedStructor.User do end end ``` + ## Documentation To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: @@ -256,6 +315,7 @@ typed_structor do field :age, non_neg_integer() end ``` + You can also document submodules this way: ```elixir @@ -275,6 +335,7 @@ end For details on creating a plugin, refer to the `TypedStructor.Plugin` module. Here is a example of `Guides.Plugins.Accessible` plugin to define `Access` behavior for the struct. + ```elixir defmodule User do use TypedStructor 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..5f55f9b 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -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`, all fields without a default value and not enforced will have `nil` added to their type. * `: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,21 @@ 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` - if set to `true`, makes the field type nullable + if `:default` is not set and `:enforce` is not set + + > ### How `:default`, `:enforce` and `:null` affect `type` and `@enforce_keys` {: .tip} + + > | **`:default`** | **`:enforce`** | **`:null`** | **`type`** | **`@enforce_keys`** | + > | -------------- | -------------- | -------------- | ----------------- | ------------------- | + > | `set` | `true` | `true` | `t()` | `excluded` | + > | `set` | `true` | `false` | `t()` | `excluded` | + > | `set` | `false` | `true` | `t()` | `excluded` | + > | `set` | `false` | `false` | `t()` | `excluded` | + > | `unset` | `true` | `true` | `t()` | **`included`** | + > | `unset` | `true` | `false` | `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..754fff6 --- /dev/null +++ b/test/null_option_test.exs @@ -0,0 +1,187 @@ +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 creation respects null option behavior", 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 can be created with nil for nullable fields + struct = struct(TestStruct, email: "test@example.com") + assert struct.id == nil + assert struct.name == nil + assert struct.email == "test@example.com" + + # All fields exist in the struct + assert Map.has_key?(struct, :id) + assert Map.has_key?(struct, :name) + assert Map.has_key?(struct, :email) + 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 From 042bfdb99779fb67150c820a906073fddc487cdb Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Sun, 21 Dec 2025 23:10:24 +0900 Subject: [PATCH 2/5] docs: rewrite README for clarity and conversational tone Restructure README to show value upfront with before/after comparison, add behavior table for enforce/default/null interaction, and improve overall readability while maintaining technical accuracy. --- README.md | 372 +++++++++++++++++++++--------------------- lib/typed_structor.ex | 22 +-- 2 files changed, 200 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index bff3f06..38acc23 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,95 +54,99 @@ 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: +This generates a struct and type where fields are nullable by default (`pos_integer() | nil`). -```elixir -defmodule User do - defstruct [:id, :name, :age] +### Enforcing Required Fields - @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() - } +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. +### Providing Defaults -### The `null` option - -By default, fields without a default value and not enforced have their type set to `type | nil`. You can control this behavior with the `null` option: +Fields with defaults aren't nullable (no reason to be): ```elixir -defmodule User do - use TypedStructor +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 +``` - typed_structor do - # This field will have type `integer()` (not nullable) - field :id, integer(), null: false +### Controlling Nullability - # This field will have type `String.t() | nil` (nullable, default behavior) - field :name, String.t(), null: true +You can explicitly control whether fields accept `nil`: - # Fields with defaults are never nullable, regardless of `null` option - field :status, String.t(), default: "active", null: true # type is `String.t()` +```elixir +typed_structor do + field :id, integer(), null: false # Never nil + field :name, String.t(), null: true # Always nullable +end +``` - # Enforced fields are never nullable, regardless of `null` option - field :email, String.t(), enforce: true, null: true # type is `String.t()` - end +Or set defaults for all fields in a block: + +```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 ``` -The `null` option is particularly useful when loading data from external sources like databases: +The interaction between `enforce`, `default`, and `null` follows this logic: + +| enforce | default | null | Type includes nil? | +|---------|---------|---------|-------------------| +| false | no | true | Yes (nullable) | +| false | no | false | No | +| false | yes | - | No | +| true | - | - | No | + +This is particularly useful when modeling database records where some fields can be `nil`: ```elixir defmodule DatabaseRecord do use TypedStructor typed_structor do - # These fields must exist in the struct but can be nil when loaded from DB + # These fields can be nil when loaded from DB field :name, String.t() field :description, String.t() - # These fields must exist and cannot be nil (e.g., primary keys, timestamps) + # 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 @@ -118,110 +154,93 @@ defmodule DatabaseRecord do end ``` -### Options - -You can also generate an `opaque` type for the struct, -even changing the type name: +## Options -```elixir -defmodule User do - use TypedStructor +TypedStructor provides several options to customize your type definitions: - typed_structor type_kind: :opaque, type_name: :profile do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() - end -end -``` +### Opaque Types -This is equivalent to: +Use `type_kind: :opaque` to hide implementation details: ```elixir -defmodule User do - use TypedStructor - - defstruct [:id, :name, :age] - - @opaque profile() :: %__MODULE__{ - id: pos_integer() | nil, - name: String.t() | nil, - age: non_neg_integer() | nil - } +typed_structor type_kind: :opaque do + field :id, pos_integer() + field :secret, String.t() end + +# Generates: @opaque t() :: %__MODULE__{...} ``` -Type parameters also can be defined: +### Custom Type Names -```elixir -defmodule User do - use TypedStructor - - typed_structor do - parameter :id - parameter :name +Override the default `t()` type name with `type_name`: - field :id, id - field :name, name - field :age, non_neg_integer() - end +```elixir +typed_structor type_name: :user_data do + field :id, pos_integer() + field :name, String.t() end + +# Generates: @type user_data() :: %__MODULE__{...} ``` -becomes: +### Type Parameters + +Create generic types with `parameter/1`: ```elixir -defmodule User do - @type t(id, name) :: %__MODULE__{ - id: id | nil, - name: name | nil, - age: non_neg_integer() | nil - } +typed_structor do + parameter :value_type + parameter :error_type - defstruct [:id, :name, :age] + field :value, value_type + field :error, error_type end + +# Generates: @type t(value_type, error_type) :: %__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. +## Common Patterns -Consider this example: +### Nested Structs + +Define structs in submodules: ```elixir defmodule User do use TypedStructor - # `%User.Profile{}` is generated typed_structor module: Profile do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() + field :email, String.t(), enforce: true + field :bio, String.t() end end + +# Creates User.Profile with its own struct and type ``` -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: +### Parameterized Types + +Create generic types with parameters: ```elixir -defmodule User do - defmodule Profile do - use TypedStructor +defmodule Container do + use TypedStructor - typed_structor do - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() - end + typed_structor do + parameter :value_type + + field :id, pos_integer() + field :value, value_type # Uses the parameter end end + +# Generates: @type t(value_type) :: %__MODULE__{...} ``` -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 @@ -229,7 +248,7 @@ defmodule User do 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 @@ -249,27 +268,11 @@ defmodule User do end ``` -Now, you can interact with these structures: +## Advanced Features -```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 +### Exceptions -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. +Define typed exceptions with automatic `__exception__` handling: ```elixir defmodule HTTPException do @@ -277,84 +280,87 @@ 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 +### Custom Types -To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: +Control type visibility and naming: ```elixir -typed_structor do - @typedoc "A typed user" - +typed_structor type_kind: :opaque, type_name: :user_data do field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() + field :secret, String.t() end -``` - -You can also document submodules this way: -```elixir -typedstructor module: Profile do - @moduledoc "A user profile struct" - @typedoc "A typed user profile" - - field :id, pos_integer() - field :name, String.t() - field :age, non_neg_integer() -end +# Generates: @opaque user_data() :: %__MODULE__{...} ``` ## Plugins -`TypedStructor` offers a plugin system to enhance functionality. -For details on creating a plugin, refer to the `TypedStructor.Plugin` module. - -Here is a example of `Guides.Plugins.Accessible` plugin to define `Access` behavior for the struct. +Extend TypedStructor's behavior with plugins. They run during compilation to add functionality: ```elixir defmodule User do use TypedStructor typed_structor do - plugin Guides.Plugins.Accessible + plugin Guides.Plugins.Accessible # Adds Access behavior 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" +user = %User{id: 1, name: "Phil"} +get_in(user, [:name]) # => "Phil" ``` -> #### Plugins guides {: .tip} +> #### Plugin 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. +> 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 + +Add documentation to your types and modules inside the block: + +```elixir +typed_structor do + @typedoc "A user with authentication details" + @moduledoc "User management structures" + + field :id, pos_integer() + field :name, String.t() +end +``` + + + +## 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/lib/typed_structor.ex b/lib/typed_structor.ex index 5f55f9b..2d1bbe6 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 [ @@ -243,16 +243,16 @@ defmodule TypedStructor do > ### How `:default`, `:enforce` and `:null` affect `type` and `@enforce_keys` {: .tip} - > | **`:default`** | **`:enforce`** | **`:null`** | **`type`** | **`@enforce_keys`** | - > | -------------- | -------------- | -------------- | ----------------- | ------------------- | - > | `set` | `true` | `true` | `t()` | `excluded` | - > | `set` | `true` | `false` | `t()` | `excluded` | - > | `set` | `false` | `true` | `t()` | `excluded` | - > | `set` | `false` | `false` | `t()` | `excluded` | - > | `unset` | `true` | `true` | `t()` | **`included`** | - > | `unset` | `true` | `false` | `t()` | **`included`** | - > | `unset` | `false` | `true` | **`t() \\| nil`** | `excluded` | - > | `unset` | `false` | `false` | **`t()`** | `excluded` | + > | **`:default`** | **`:enforce`** | **`:null`** | **`type`** | **`@enforce_keys`** | + > | -------------- | -------------- | ----------- | ----------------- | ------------------- | + > | `set` | `true` | `true` | `t()` | `excluded` | + > | `set` | `true` | `false` | `t()` | `excluded` | + > | `set` | `false` | `true` | `t()` | `excluded` | + > | `set` | `false` | `false` | `t()` | `excluded` | + > | `unset` | `true` | `true` | `t()` | **`included`** | + > | `unset` | `true` | `false` | `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)) From f7894afbac220d5be2d4131792b6b02f278e3de3 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Sun, 21 Dec 2025 23:42:40 +0900 Subject: [PATCH 3/5] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate 'Parameterized Types' section (already covered in 'Type Parameters') - Remove duplicate 'Custom Types' section (already covered in 'Opaque Types') - Fix @moduledoc placement in Documentation example (should be at module level) - Rename test to accurately describe what it tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 46 ++++++++------------------------------- test/null_option_test.exs | 2 +- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 38acc23..ebc440d 100644 --- a/README.md +++ b/README.md @@ -219,25 +219,6 @@ end # Creates User.Profile with its own struct and type ``` -### Parameterized Types - -Create generic types with parameters: - -```elixir -defmodule Container do - use TypedStructor - - typed_structor do - parameter :value_type - - field :id, pos_integer() - field :value, value_type # Uses the parameter - end -end - -# Generates: @type t(value_type) :: %__MODULE__{...} -``` - ### Integration with Other Tools Skip struct generation to use with Ecto or other schema tools: @@ -305,19 +286,6 @@ defmodule UserRecord do end ``` -### Custom Types - -Control type visibility and naming: - -```elixir -typed_structor type_kind: :opaque, type_name: :user_data do - field :id, pos_integer() - field :secret, String.t() -end - -# Generates: @opaque user_data() :: %__MODULE__{...} -``` - ## Plugins Extend TypedStructor's behavior with plugins. They run during compilation to add functionality: @@ -345,15 +313,19 @@ get_in(user, [:name]) # => "Phil" ## Documentation -Add documentation to your types and modules inside the block: +Add `@moduledoc` at the module level, and `@typedoc` inside the block: ```elixir -typed_structor do - @typedoc "A user with authentication details" +defmodule User do @moduledoc "User management structures" + use TypedStructor - field :id, pos_integer() - field :name, String.t() + typed_structor do + @typedoc "A user with authentication details" + + field :id, pos_integer() + field :name, String.t() + end end ``` diff --git a/test/null_option_test.exs b/test/null_option_test.exs index 754fff6..6588804 100644 --- a/test/null_option_test.exs +++ b/test/null_option_test.exs @@ -39,7 +39,7 @@ defmodule TypedStructor.NullOptionTest do end @tag :tmp_dir - test "struct creation respects null option behavior", ctx do + test "struct fields default to nil regardless of null option", ctx do with_tmpmodule TestStruct, ctx do use TypedStructor From 0835f600124ac901989f34cc7ae7cfc64d84568e Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Mon, 22 Dec 2025 00:03:57 +0900 Subject: [PATCH 4/5] docs: improve null option documentation clarity - Clarify that null: true is the default behavior - Condense option tables from 8 rows to 4 using "-" for irrelevant values - Use consistent column order (default, enforce, null) across tables - Improve wording for "Providing Defaults" section --- README.md | 14 +++++++------- lib/typed_structor.ex | 15 ++++++--------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ebc440d..5b18073 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ end ### Providing Defaults -Fields with defaults aren't nullable (no reason to be): +Fields with defaults don't need to be nullable since they always have a value: ```elixir typed_structor do @@ -128,12 +128,12 @@ end The interaction between `enforce`, `default`, and `null` follows this logic: -| enforce | default | null | Type includes nil? | -|---------|---------|---------|-------------------| -| false | no | true | Yes (nullable) | -| false | no | false | No | -| false | yes | - | No | -| true | - | - | No | +| `: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`: diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 2d1bbe6..d7a6079 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -27,7 +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`, all fields without a default value and not enforced will have `nil` added to their type. + * `: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`. @@ -238,19 +238,16 @@ 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 - * `:null` - if set to `true`, makes the field type nullable - if `:default` is not set and `:enforce` is not set + * `: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` | `true` | `true` | `t()` | `excluded` | - > | `set` | `true` | `false` | `t()` | `excluded` | - > | `set` | `false` | `true` | `t()` | `excluded` | - > | `set` | `false` | `false` | `t()` | `excluded` | - > | `unset` | `true` | `true` | `t()` | **`included`** | - > | `unset` | `true` | `false` | `t()` | **`included`** | + > | `set` | - | - | `t()` | `excluded` | + > | `unset` | `true` | - | `t()` | **`included`** | > | `unset` | `false` | `true` | **`t() \\| nil`** | `excluded` | > | `unset` | `false` | `false` | `t()` | `excluded` | """ From f530b1646a074234ba53c00e822da6b36c3bb7cb Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Mon, 22 Dec 2025 21:46:44 +0900 Subject: [PATCH 5/5] fixup! fix: address PR review comments --- test/null_option_test.exs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/null_option_test.exs b/test/null_option_test.exs index 6588804..a8e87a1 100644 --- a/test/null_option_test.exs +++ b/test/null_option_test.exs @@ -49,16 +49,11 @@ defmodule TypedStructor.NullOptionTest do field :email, String.t(), enforce: true end after - # Struct can be created with nil for nullable fields + # 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" - - # All fields exist in the struct - assert Map.has_key?(struct, :id) - assert Map.has_key?(struct, :name) - assert Map.has_key?(struct, :email) end end end