diff --git a/README.md b/README.md index 1b5a4d6..d0386c7 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,26 @@ 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__`. +To define an exception, use the `defexception` definer within the `typed_structor` block. + +```elixir +defmodule HTTPException do + use TypedStructor + + typed_structor definer: :defexception, enforce: true do + field :status, non_neg_integer() + end + + @impl Exception + def message(%__MODULE__{status: status}) do + "HTTP status #{status}" + end +end +``` + ## Documentation To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 15a3bbc..7d0cb61 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -25,12 +25,18 @@ defmodule TypedStructor do * `:type_name` - the name of the type to use for the struct. Defaults to `t`. ## Definer - There are one available definer for now, `:defstruct`, which defines a struct and a type for a given definition. + The available definers are: + - `:defstruct`, which defines a struct and a type for a given definition + - `:defexception`, which defines an exception and a type for a given definition ### `:defstruct` options * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + ### `:defexception` options + + * `:define_struct` - if `false`, the type will be defined, but the exception struct will not be defined. Defaults to `true`. + ### custom definer defmodule MyStruct do @@ -273,6 +279,11 @@ defmodule TypedStructor do # credo:disable-for-next-line Credo.Check.Design.AliasUsage TypedStructor.Definer.Defstruct.define(definition) + :defexception -> + require TypedStructor.Definer.Defexception + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + TypedStructor.Definer.Defexception.define(definition) + fun when is_function(fun) -> then(definition, fun) end diff --git a/lib/typed_structor/definer/defexception.ex b/lib/typed_structor/definer/defexception.ex new file mode 100644 index 0000000..6e8afaa --- /dev/null +++ b/lib/typed_structor/definer/defexception.ex @@ -0,0 +1,46 @@ +defmodule TypedStructor.Definer.Defexception do + @moduledoc """ + A definer to define an exception and a type for a given definition. + + ## Additional options for `typed_structor` + + * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + + ## Usage + + defmodule MyException do + use TypedStructor + + typed_structor definer: :defexception, define_struct: false do + field :message, String.t() + end + end + """ + + alias TypedStructor.Definer.Defstruct + + @doc """ + Defines an exception and a type for a given definition. + """ + defmacro define(definition) do + quote do + unquote(__MODULE__).__exception_ast__(unquote(definition)) + + require Defstruct + Defstruct.__type_ast__(unquote(definition)) + end + end + + @doc false + defmacro __exception_ast__(definition) do + quote bind_quoted: [definition: definition] do + if Keyword.get(definition.options, :define_struct, true) do + {fields, enforce_keys} = + Defstruct.__extract_fields_and_enforce_keys__(definition) + + @enforce_keys Enum.reverse(enforce_keys) + defexception fields + end + end + end +end diff --git a/lib/typed_structor/definer/defstruct.ex b/lib/typed_structor/definer/defstruct.ex index 7d2a57d..fa2dcb7 100644 --- a/lib/typed_structor/definer/defstruct.ex +++ b/lib/typed_structor/definer/defstruct.ex @@ -30,29 +30,33 @@ defmodule TypedStructor.Definer.Defstruct do @doc false defmacro __struct_ast__(definition) do - ast = - quote do - {fields, enforce_keys} = - Enum.map_reduce(unquote(definition).fields, [], fn field, acc -> - name = Keyword.fetch!(field, :name) - default = Keyword.get(field, :default) + alias TypedStructor.Definer.Defstruct - if Keyword.get(field, :enforce, false) and not Keyword.has_key?(field, :default) do - {{name, default}, [name | acc]} - else - {{name, default}, acc} - end - end) + quote bind_quoted: [definition: definition] do + if Keyword.get(definition.options, :define_struct, true) do + {fields, enforce_keys} = + Defstruct.__extract_fields_and_enforce_keys__(definition) @enforce_keys Enum.reverse(enforce_keys) defstruct fields end + end + end - quote do - if Keyword.get(unquote(definition).options, :define_struct, true) do - unquote(ast) + @doc false + @spec __extract_fields_and_enforce_keys__(TypedStructor.Definition.t()) :: + {Keyword.t(), [atom()]} + def __extract_fields_and_enforce_keys__(definition) do + Enum.map_reduce(definition.fields, [], fn field, acc -> + name = Keyword.fetch!(field, :name) + default = Keyword.get(field, :default) + + if Keyword.get(field, :enforce, false) and not Keyword.has_key?(field, :default) do + {{name, default}, [name | acc]} + else + {{name, default}, acc} end - end + end) end @doc false diff --git a/test/definer_test.exs b/test/definer_test.exs index 2aca2a5..601490b 100644 --- a/test/definer_test.exs +++ b/test/definer_test.exs @@ -1,5 +1,6 @@ defmodule DefinerTest do @compile {:no_warn_undefined, __MODULE__.Struct} + @compile {:no_warn_undefined, __MODULE__.MyException} use TypedStructor.TestCase, async: true @@ -82,4 +83,66 @@ defmodule DefinerTest do cleanup_modules([__MODULE__.Struct], ctx.tmp_dir) end end + + describe "defexception" do + @tag :tmp_dir + test "works", ctx do + expected_types = + with_tmpmodule MyException, ctx do + @type t() :: %__MODULE__{ + message: String.t() | nil + } + + defexception [:message] + after + fetch_types!(MyException) + end + + generated_types = + with_tmpmodule MyException, ctx do + use TypedStructor + + typed_structor definer: :defexception do + field :message, String.t() + end + + @impl Exception + def exception(arguments) do + %__MODULE__{message: Keyword.fetch!(arguments, :message)} + end + + @impl Exception + def message(%__MODULE__{message: message}) do + message + end + after + exception = MyException.exception(message: "this is an error") + assert is_exception(exception) + assert "this is an error" === Exception.message(exception) + fetch_types!(MyException) + end + + assert_type expected_types, generated_types + end + + @tag :tmp_dir + test "define_struct false", ctx do + deftmpmodule MyException, ctx do + use TypedStructor + + typed_structor define_struct: false do + parameter :message + + field :message, message + end + + defexception message: "error" + end + + assert %{__struct__: MyException, __exception__: true, message: "error"} === + struct(MyException) + after + cleanup_modules([__MODULE__.MyException], ctx.tmp_dir) + end + end end