Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# SPDX-FileCopyrightText: NONE
# SPDX-License-Identifier: CC0-1.0

locals_without_parens = [field: 2, field: 3, plugin: 1, plugin: 2]
locals_without_parens = [
field: 2,
field: 3,
parameter: 1,
parameter: 2,
plugin: 1,
plugin: 2
]

[
inputs: [
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).

### Added

* Add support for type parameters (by [@fahchen](https://github.com/fahchen/)).
* Add a `:doc` option to `field/3` to add field-specific descriptions in the
`@typedoc` (by [@javobalazs](https://github.com/javobalazs)).

Expand Down
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,32 @@ defmodule MyOpaqueStruct do
end
```

You can add type parameters:

```elixir
defmodule User do
use TypedStruct

typedstruct do
# Define a type parameter with the `parameter` macro.
parameter :state

# You can then use it here as `state`.
field :state, state, enforce: true
field :name, String.t()
end
end
```

And use them like this:

```elixir
@type user_state() :: :registered | :confirmed | :logged_in

@spec get_user_state(User.t(user_state())) :: user_state()
def get_user_state(%User{state, _name}), do: state
```

If you often define submodules containing only a struct, you can avoid
boilerplate code:

Expand Down Expand Up @@ -203,14 +229,17 @@ typedstruct do
end
```

You can also document individual fields:
You can also document individual type parameters and fields:

```elixir
typedstruct do
@typedoc "A typed struct"

parameter :state, doc: "type for the `:state` field"

field :a_string, String.t(), doc: "just a series of letters"
field :an_int, integer(), doc: "some explanation"
field :state, state, doc: "the current state"
end
```

Expand All @@ -220,6 +249,10 @@ This generate the following `@typedoc`:
@typedoc """
A typed struct

## Type parameters

- `state` - type for the `:state` field

## Fields

- `a_string` - just a series of letters
Expand Down Expand Up @@ -380,10 +413,29 @@ generates the following type:

```elixir
@opaque t() :: %__MODULE__{
name: String.t()
name: String.t() | nil
}
```

When you specify parameters with the `parameter/1` macro, they are used in the
definition of the type:

```elixir
typedstruct do
parameter :param

field :field, param
end
```

gives the following type:

```elixir
@type t(param) :: %__MODULE__{
field: param | nil
}
```

When passing `module: ModuleName`, the whole `typedstruct` block is wrapped in a
module definition. This way, the following definition:

Expand Down
107 changes: 86 additions & 21 deletions lib/typed_struct.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
# SPDX-FileCopyrightText: 2022 Jonathan Chukinas <chukinas@gmail.com>
# SPDX-FileCopyrightText: 2022 Balázs Jávorszky <javorszky.balazs@estyle.hu>
# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com>
#
# SPDX-License-Identifier: MIT

Expand All @@ -15,9 +16,11 @@ defmodule TypedStruct do
@accumulating_attrs [
:ts_plugins,
:ts_plugin_fields,
:ts_parameters,
:ts_parameter_docs,
:ts_fields,
:ts_types,
:ts_docs,
:ts_field_docs,
:ts_enforce_keys
]

Expand Down Expand Up @@ -70,6 +73,18 @@ defmodule TypedStruct do
end
end

You can also add type parameters:

defmodule MyModule do
use TypedStruct

typedstruct do
parameter :type

field :field, type
end
end

You can create the struct in a submodule instead:

defmodule MyModule do
Expand Down Expand Up @@ -118,36 +133,48 @@ defmodule TypedStruct do
@enforce_keys @ts_enforce_keys
defstruct @ts_fields

TypedStruct.__typedoc__(@ts_docs)
TypedStruct.__type__(@ts_types, unquote(opts))
TypedStruct.__typedoc__(@ts_parameter_docs, @ts_field_docs)

TypedStruct.__type__(
Enum.reverse(@ts_parameters),
@ts_types,
unquote(opts)
)
end
end

@doc false
defmacro __typedoc__(docs) do
quote bind_quoted: [docs: docs] do
defmacro __typedoc__(parameter_docs, field_docs) do
quote bind_quoted: [
parameter_docs: parameter_docs,
field_docs: field_docs
] do
parameter_docs =
parameter_docs
|> Enum.reverse()
|> Enum.filter(fn {_, doc} -> !is_nil(doc) end)
|> Enum.map_join("\n", fn {name, doc} -> "- `#{name}` - #{doc}" end)
|> TypedStruct.__add_heading__("Type parameters")

field_docs =
docs
field_docs
|> Enum.reverse()
|> Enum.filter(fn {_, doc} -> !is_nil(doc) end)
|> Enum.map(fn {name, doc} -> "- `#{name}` - #{doc}" end)
|> Enum.map_join("\n", fn {name, doc} -> "- `#{name}` - #{doc}" end)
|> TypedStruct.__add_heading__("Fields")

if field_docs != [] do
if parameter_docs != "" || field_docs != "" do
# If there are field docs, we complete the `@typedoc` with field
# documentation. However, if there is no `@typedoc` already, let’s emit
# a warning instead.
if Module.has_attribute?(__MODULE__, :typedoc) do
@typedoc """
#{@typedoc}

## Fields

#{Enum.join(field_docs, "\n")}
#{@typedoc}#{parameter_docs}#{field_docs}
"""
else
IO.warn(
"""
adding field documentation has no effect without a @typedoc
adding parameter or field documentation has no effect without a @typedoc

hint: add a @typedoc on your `typedstruct` definition
""",
Expand All @@ -159,14 +186,22 @@ defmodule TypedStruct do
end

@doc false
defmacro __type__(types, opts) do
def __add_heading__("", _heading), do: ""
def __add_heading__(doc, heading), do: "\n\n## #{heading}\n\n#{doc}"

@doc false
defmacro __type__(parameters, types, opts) do
if Keyword.get(opts, :opaque, false) do
quote bind_quoted: [types: types] do
@opaque t() :: %__MODULE__{unquote_splicing(types)}
quote bind_quoted: [parameters: parameters, types: types] do
@opaque t(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(types)
}
end
else
quote bind_quoted: [types: types] do
@type t() :: %__MODULE__{unquote_splicing(types)}
quote bind_quoted: [parameters: parameters, types: types] do
@type t(unquote_splicing(parameters)) :: %__MODULE__{
unquote_splicing(types)
}
end
end
end
Expand Down Expand Up @@ -199,6 +234,36 @@ defmodule TypedStruct do
end
end

@doc """
Defines a type parameter for the currently defined struct.

## Example

typedstruct do
# Defines a type parameter named `type_param`
parameter :type_param

# The type parameter can be used as a type in the `field` macro.
field :a_field, type_param
end
"""
defmacro parameter(name, opts \\ []) do
quote bind_quoted: [name: name, opts: opts] do
TypedStruct.__parameter__(name, opts, __ENV__)
end
end

@doc false
def __parameter__(name, opts, %Macro.Env{module: mod}) when is_atom(name) do
Module.put_attribute(mod, :ts_parameters, Macro.var(name, mod))
Module.put_attribute(mod, :ts_parameter_docs, {name, opts[:doc]})
end

def __parameter__(name, _opts, _env) do
raise ArgumentError,
"the name of a type parameter must be an atom, got #{inspect(name)}"
end

@doc """
Defines a field in a typed struct.

Expand Down Expand Up @@ -247,14 +312,14 @@ defmodule TypedStruct do
Module.put_attribute(mod, :ts_fields, {name, opts[:default]})
Module.put_attribute(mod, :ts_plugin_fields, {name, type, opts, env})
Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)})
Module.put_attribute(mod, :ts_docs, {name, opts[:doc]})
Module.put_attribute(mod, :ts_field_docs, {name, opts[:doc]})
if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name)
end

# Checks whether some value looks like Elixir AST.
defp ast?({name, meta, params})
when (is_atom(name) or is_tuple(name)) and is_list(meta) and
is_list(params),
(is_list(params) or is_nil(params)),
do: true

defp ast?(_), do: false
Expand Down
68 changes: 66 additions & 2 deletions test/support/test_struct.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# SPDX-FileCopyrightText: 2018, 2020, 2025 Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com>
# SPDX-FileCopyrightText: 2023 Serge Aleynikov <saleyn@gmail.com>
#
# SPDX-License-Identifier: MIT
Expand Down Expand Up @@ -82,6 +83,37 @@ defmodule TypedStruct.TestStruct do
end
end

defmodule WithParameter do
@moduledoc """
A struct with a parameterised type.
"""
use TypedStruct

typedstruct do
parameter :t1
parameter :t2

field :field_t1, t1
field :field_t2, t2
field :enforced_field_t1, t1, enforce: true
end

defmodule Expected do
@moduledoc """
`WithParameter` but defined manually.
"""

@enforce_keys [:enforced_field_t1]
defstruct [:field_t1, :field_t2, :enforced_field_t1]

@type t(t1, t2) :: %__MODULE__{
field_t1: t1 | nil,
field_t2: t2 | nil,
enforced_field_t1: t1
}
end
end

defmodule AsSubmodule do
@moduledoc """
A struct defined as a submodule.
Expand All @@ -105,9 +137,25 @@ defmodule TypedStruct.TestStruct do
end
end

defmodule DetailedTypedoc do
defmodule ParametersTypedoc do
@moduledoc """
A typed struct with a `@typedoc`.
A typed struct with a `@typedoc` and type parameter docs.
"""
use TypedStruct

@typedoc "A typed struct"
typedstruct do
parameter :string, doc: "the string type of your choice"
parameter :int, doc: "the integer type of your choice"

field :a_string, string
field :an_int, int
end
end

defmodule FieldsTypedoc do
@moduledoc """
A typed struct with a `@typedoc` and field docs.
"""
use TypedStruct

Expand All @@ -118,6 +166,22 @@ defmodule TypedStruct.TestStruct do
end
end

defmodule ParametersAndFieldsTypedoc do
@moduledoc """
A typed struct with a `@typedoc` and both parameter and field docs
"""
use TypedStruct

@typedoc "A typed struct"
typedstruct do
parameter :string, doc: "the string type of your choice"
parameter :int, doc: "the integer type of your choice"

field :a_string, string, doc: "just a series of letters"
field :an_int, int, doc: "some digits"
end
end

defmodule Alias do
@moduledoc """
Structs for testing the use of aliases in types.
Expand Down
Loading
Loading