Skip to content

Commit 2a07149

Browse files
fahchenejpcmac
andcommitted
feat: add type parameters
Co-authored-by: Phil Chen <06fahchen@gmail.com> Co-authored-by: Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
1 parent da6414f commit 2a07149

5 files changed

Lines changed: 177 additions & 10 deletions

File tree

.formatter.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-FileCopyrightText: NONE
22
# SPDX-License-Identifier: CC0-1.0
33

4-
locals_without_parens = [field: 2, field: 3, plugin: 1, plugin: 2]
4+
locals_without_parens = [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2]
55

66
[
77
inputs: [

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,32 @@ defmodule MyOpaqueStruct do
175175
end
176176
```
177177

178+
You can add type parameters:
179+
180+
```elixir
181+
defmodule User do
182+
use TypedStruct
183+
184+
typedstruct do
185+
# Define a type parameter with the `parameter` macro.
186+
parameter :state
187+
188+
# You can then use it here as `state`.
189+
field :state, state, enforce: true
190+
field :name, String.t()
191+
end
192+
end
193+
```
194+
195+
And use them like this:
196+
197+
```elixir
198+
@type user_state() :: :registered | :confirmed | :logged_in
199+
200+
@spec get_user_state(User.t(user_state())) :: user_state()
201+
def get_user_state(%User{state, _name}), do: state
202+
```
203+
178204
If you often define submodules containing only a struct, you can avoid
179205
boilerplate code:
180206

@@ -380,10 +406,29 @@ generates the following type:
380406

381407
```elixir
382408
@opaque t() :: %__MODULE__{
383-
name: String.t()
409+
name: String.t() | nil
384410
}
385411
```
386412

413+
When you specify parameters with the `parameter/1` macro, they are used in the
414+
definition of the type:
415+
416+
```elixir
417+
typedstruct do
418+
parameter :param
419+
420+
field :field, param
421+
end
422+
```
423+
424+
gives the following type:
425+
426+
```elixir
427+
@type t(param) :: %__MODULE__{
428+
field: param | nil
429+
}
430+
```
431+
387432
When passing `module: ModuleName`, the whole `typedstruct` block is wrapped in a
388433
module definition. This way, the following definition:
389434

lib/typed_struct.ex

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
33
# SPDX-FileCopyrightText: 2022 Jonathan Chukinas <chukinas@gmail.com>
44
# SPDX-FileCopyrightText: 2022 Balázs Jávorszky <javorszky.balazs@estyle.hu>
5+
# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com>
56
#
67
# SPDX-License-Identifier: MIT
78

@@ -15,6 +16,7 @@ defmodule TypedStruct do
1516
@accumulating_attrs [
1617
:ts_plugins,
1718
:ts_plugin_fields,
19+
:ts_parameters,
1820
:ts_fields,
1921
:ts_types,
2022
:ts_docs,
@@ -70,6 +72,18 @@ defmodule TypedStruct do
7072
end
7173
end
7274
75+
You can also add type parameters:
76+
77+
defmodule MyModule do
78+
use TypedStruct
79+
80+
typedstruct do
81+
parameter :type
82+
83+
field :field, type
84+
end
85+
end
86+
7387
You can create the struct in a submodule instead:
7488
7589
defmodule MyModule do
@@ -119,7 +133,12 @@ defmodule TypedStruct do
119133
defstruct @ts_fields
120134

121135
TypedStruct.__typedoc__(@ts_docs)
122-
TypedStruct.__type__(@ts_types, unquote(opts))
136+
137+
TypedStruct.__type__(
138+
Enum.reverse(@ts_parameters),
139+
@ts_types,
140+
unquote(opts)
141+
)
123142
end
124143
end
125144

@@ -159,14 +178,18 @@ defmodule TypedStruct do
159178
end
160179

161180
@doc false
162-
defmacro __type__(types, opts) do
181+
defmacro __type__(parameters, types, opts) do
163182
if Keyword.get(opts, :opaque, false) do
164-
quote bind_quoted: [types: types] do
165-
@opaque t() :: %__MODULE__{unquote_splicing(types)}
183+
quote bind_quoted: [parameters: parameters, types: types] do
184+
@opaque t(unquote_splicing(parameters)) :: %__MODULE__{
185+
unquote_splicing(types)
186+
}
166187
end
167188
else
168-
quote bind_quoted: [types: types] do
169-
@type t() :: %__MODULE__{unquote_splicing(types)}
189+
quote bind_quoted: [parameters: parameters, types: types] do
190+
@type t(unquote_splicing(parameters)) :: %__MODULE__{
191+
unquote_splicing(types)
192+
}
170193
end
171194
end
172195
end
@@ -199,6 +222,35 @@ defmodule TypedStruct do
199222
end
200223
end
201224

225+
@doc """
226+
Defines a type parameter for the currently defined struct.
227+
228+
## Example
229+
230+
typedstruct do
231+
# Defines a type parameter named `type_param`
232+
parameter :type_param
233+
234+
# The type parameter can be used as a type in the `field` macro.
235+
field :a_field, type_param
236+
end
237+
"""
238+
defmacro parameter(name) do
239+
quote bind_quoted: [name: name] do
240+
TypedStruct.__parameter__(name, __ENV__)
241+
end
242+
end
243+
244+
@doc false
245+
def __parameter__(name, %Macro.Env{module: mod}) when is_atom(name) do
246+
Module.put_attribute(mod, :ts_parameters, Macro.var(name, mod))
247+
end
248+
249+
def __parameter__(name, _env) do
250+
raise ArgumentError,
251+
"the name of a type parameter must be an atom, got #{inspect(name)}"
252+
end
253+
202254
@doc """
203255
Defines a field in a typed struct.
204256
@@ -254,7 +306,7 @@ defmodule TypedStruct do
254306
# Checks whether some value looks like Elixir AST.
255307
defp ast?({name, meta, params})
256308
when (is_atom(name) or is_tuple(name)) and is_list(meta) and
257-
is_list(params),
309+
(is_list(params) or is_nil(params)),
258310
do: true
259311

260312
defp ast?(_), do: false

test/support/test_struct.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SPDX-FileCopyrightText: 2018, 2020, 2025 Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
22
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
3+
# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com>
34
# SPDX-FileCopyrightText: 2023 Serge Aleynikov <saleyn@gmail.com>
45
#
56
# SPDX-License-Identifier: MIT
@@ -82,6 +83,37 @@ defmodule TypedStruct.TestStruct do
8283
end
8384
end
8485

86+
defmodule WithParameter do
87+
@moduledoc """
88+
A struct with a parameterised type.
89+
"""
90+
use TypedStruct
91+
92+
typedstruct do
93+
parameter :t1
94+
parameter :t2
95+
96+
field :field_t1, t1
97+
field :field_t2, t2
98+
field :enforced_field_t1, t1, enforce: true
99+
end
100+
101+
defmodule Expected do
102+
@moduledoc """
103+
`WithParameter` but defined manually.
104+
"""
105+
106+
@enforce_keys [:enforced_field_t1]
107+
defstruct [:field_t1, :field_t2, :enforced_field_t1]
108+
109+
@type t(t1, t2) :: %__MODULE__{
110+
field_t1: t1 | nil,
111+
field_t2: t2 | nil,
112+
enforced_field_t1: t1
113+
}
114+
end
115+
end
116+
85117
defmodule AsSubmodule do
86118
@moduledoc """
87119
A struct defined as a submodule.

test/typed_struct_test.exs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SPDX-FileCopyrightText: 2018, 2020, 2022, 2025 Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
22
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
3+
# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com>
34
#
45
# SPDX-License-Identifier: MIT
56

@@ -79,6 +80,22 @@ defmodule TypedStructTest do
7980
assert type1 == type2
8081
end
8182

83+
test "generates a parameterized type for the struct" do
84+
# Get both types and standardise them (remove line numbers and rename
85+
# the second struct with the name of the first one).
86+
type1 =
87+
TestStruct.WithParameter
88+
|> extract_first_type()
89+
|> standardise(TestStruct.WithParameter)
90+
91+
type2 =
92+
TestStruct.WithParameter.Expected
93+
|> extract_first_type()
94+
|> standardise(TestStruct.WithParameter.Expected)
95+
96+
assert type1 == type2
97+
end
98+
8299
test "generates the struct in a submodule if `module: ModuleName` is set" do
83100
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
84101
assert TestStruct.AsSubmodule.Struct.__struct__() ==
@@ -163,6 +180,21 @@ defmodule TypedStructTest do
163180
end) =~ "undefined function field/2"
164181
end
165182

183+
test "the name of a type parameter be an atom" do
184+
assert_raise ArgumentError,
185+
"the name of a type parameter must be an atom, got 3",
186+
fn ->
187+
defmodule InvalidStruct do
188+
use TypedStruct
189+
190+
typedstruct do
191+
parameter 3
192+
field :field, term()
193+
end
194+
end
195+
end
196+
end
197+
166198
test "the name of a field must be an atom" do
167199
assert_raise ArgumentError, "a field name must be an atom, got 3", fn ->
168200
defmodule InvalidStruct do
@@ -237,14 +269,20 @@ defmodule TypedStructTest do
237269
defp standardise({:type, _, type, params}, struct),
238270
do: {:type, :line, type, standardise(params, struct)}
239271

272+
defp standardise({:user_type, _, type, params}, struct),
273+
do: {:user_type, :line, type, standardise(params, struct)}
274+
240275
defp standardise({:remote_type, _, params}, struct),
241276
do: {:remote_type, :line, standardise(params, struct)}
242277

243278
defp standardise({:atom, _, struct}, struct),
244279
do: {:atom, :line, TestStruct}
245280

281+
defp standardise({:var, _, name}, _),
282+
do: {:var, :line, name}
283+
246284
defp standardise({name, type, params}, struct) when is_tuple(type),
247-
do: {name, standardise(type, struct), params}
285+
do: {name, standardise(type, struct), standardise(params, struct)}
248286

249287
defp standardise({type, _, literal}, _struct),
250288
do: {type, :line, literal}

0 commit comments

Comments
 (0)