Skip to content

Commit d74dff2

Browse files
committed
Transform the struct update syntax into a type assertion
This transforms the struct update into a type assertion, requiring the type system to be sure the expression has precisely the given struct type. The struct update syntax may still be deprecated in the future but this will provide a safer migration path and allow us to engage in more conversations with the community.
1 parent 59a1ad9 commit d74dff2

File tree

3 files changed

+145
-14
lines changed

3 files changed

+145
-14
lines changed

lib/elixir/lib/module/types/expr.ex

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,21 +231,36 @@ defmodule Module.Types.Expr do
231231
# %Struct{map | ...}
232232
# This syntax is deprecated, so we simply traverse.
233233
def of_expr(
234-
{:%, _, [_, {:%{}, _, [{:|, _, [map, args]}]}]} = struct,
234+
{:%, meta, [module, {:%{}, _, [{:|, _, [map, pairs]}]}]} = struct,
235235
_expected,
236236
expr,
237237
stack,
238238
context
239239
) do
240-
{_, context} = of_expr(map, term(), struct, stack, context)
240+
{map_type, context} = of_expr(map, term(), struct, stack, context)
241241

242242
context =
243-
Enum.reduce(args, context, fn {key, value}, context when is_atom(key) ->
244-
{_, context} = of_expr(value, term(), expr, stack, context)
243+
if stack.mode == :traversal do
245244
context
246-
end)
245+
else
246+
with {false, struct_key_type} <- map_fetch(map_type, :__struct__),
247+
{:finite, [^module]} <- atom_fetch(struct_key_type) do
248+
context
249+
else
250+
_ ->
251+
error(__MODULE__, {:badupdate, map_type, struct, context}, meta, stack, context)
252+
end
253+
end
247254

248-
{dynamic(), context}
255+
Enum.reduce(pairs, {map_type, context}, fn {key, value}, {acc, context} ->
256+
# TODO: Once we support typed structs, we need to type check them here
257+
{type, context} = of_expr(value, term(), expr, stack, context)
258+
259+
case map_fetch_and_put(acc, key, type) do
260+
{_value, acc} -> {acc, context}
261+
_ -> {acc, context}
262+
end
263+
end)
249264
end
250265

251266
# %{...}
@@ -791,6 +806,55 @@ defmodule Module.Types.Expr do
791806

792807
## Warning formatting
793808

809+
def format_diagnostic({:badupdate, type, expr, context}) do
810+
{:%, _, [module, {:%{}, _, [{:|, _, [map, _]}]}]} = expr
811+
traces = collect_traces(map, context)
812+
813+
suggestion =
814+
case map do
815+
{var, meta, context} when is_atom(var) and is_atom(context) ->
816+
if capture = meta[:capture] do
817+
"instead of using &#{capture}, you must define an anonymous function, define a variable and pattern match on \"%#{inspect(module)}{}\""
818+
else
819+
"when defining the variable \"#{Macro.to_string(map)}\", you must also pattern match on \"%#{inspect(module)}{}\""
820+
end
821+
822+
_ ->
823+
"you must assign \"#{Macro.to_string(map)}\" to variable and pattern match on \"%#{inspect(module)}{}\""
824+
end
825+
826+
%{
827+
details: %{typing_traces: traces},
828+
message:
829+
IO.iodata_to_binary([
830+
"""
831+
a struct for #{inspect(module)} is expected on struct update:
832+
833+
#{expr_to_string(expr, collapse_structs: false) |> indent(4)}
834+
835+
but got type:
836+
837+
#{to_quoted_string(type) |> indent(4)}
838+
""",
839+
format_traces(traces),
840+
"""
841+
842+
#{hint()} #{suggestion}. Given pattern matching is enough to catch typing errors, \
843+
you may optionally convert the struct update into a map update. For example, \
844+
instead of:
845+
846+
user = some_fun()
847+
%User{user | name: "John Doe"}
848+
849+
it is enough to write:
850+
851+
%User{} = user = some_fun()
852+
%{user | name: "John Doe"}
853+
"""
854+
])
855+
}
856+
end
857+
794858
def format_diagnostic({:badmap, type, expr, context}) do
795859
traces = collect_traces(expr, context)
796860

lib/elixir/src/elixir_map.erl

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ expand_map(Meta, Args, S, E) ->
1818
validate_kv(Meta, EArgs, Args, E),
1919
{{'%{}', Meta, EArgs}, SE, EE}.
2020

21-
expand_struct(Meta, Left, {'%{}', MapMeta, MapArgs} = Right, S, #{context := Context} = E) ->
21+
expand_struct(Meta, Left, {'%{}', MapMeta, MapArgs}, S, #{context := Context} = E) ->
2222
CleanMapArgs = delete_struct_key(Meta, MapArgs, E),
2323
{[ELeft, ERight], SE, EE} = elixir_expand:expand_args([Left, {'%{}', MapMeta, CleanMapArgs}], S, E),
2424

@@ -29,7 +29,6 @@ expand_struct(Meta, Left, {'%{}', MapMeta, MapArgs} = Right, S, #{context := Con
2929
%% The update syntax for structs is deprecated,
3030
%% so we return only the update syntax downstream.
3131
%% TODO: Remove me on Elixir v2.0
32-
file_warn(MapMeta, ?key(E, file), ?MODULE, {deprecated_update, ELeft, Right}),
3332
_ = load_struct_info(Meta, ELeft, Assocs, EE),
3433
{{'%', Meta, [ELeft, ERight]}, SE, EE};
3534

@@ -303,9 +302,4 @@ format_error({invalid_key_for_struct, Key}) ->
303302
io_lib:format("invalid key ~ts for struct, struct keys must be atoms, got: ",
304303
['Elixir.Macro':to_string(Key)]);
305304
format_error(ignored_struct_key_in_struct) ->
306-
"key :__struct__ is ignored when using structs";
307-
format_error({deprecated_update, Struct, MapUpdate}) ->
308-
io_lib:format("the struct update syntax is deprecated:\n\n~ts\n\n"
309-
"Instead, prefer to pattern match on structs when the variable is first defined and "
310-
"use the regular map update syntax instead:\n\n~ts\n",
311-
['Elixir.Macro':to_string({'%', [], [Struct, MapUpdate]}), 'Elixir.Macro':to_string(MapUpdate)]).
305+
"key :__struct__ is ignored when using structs".

lib/elixir/test/elixir/module/types/expr_test.exs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,79 @@ defmodule Module.Types.ExprTest do
871871
"""
872872
end
873873

874+
test "updating structs" do
875+
# When we know the type
876+
assert typecheck!([], %Date{Date.new!(1, 1, 1) | day: 31}) ==
877+
dynamic(
878+
closed_map(
879+
__struct__: atom([Date]),
880+
day: integer(),
881+
calendar: term(),
882+
month: term(),
883+
year: term()
884+
)
885+
)
886+
887+
# When we don't know the type of var
888+
assert typeerror!([x], %Date{x | day: 31}) == ~l"""
889+
a struct for Date is expected on struct update:
890+
891+
%Date{x | day: 31}
892+
893+
but got type:
894+
895+
dynamic()
896+
897+
where "x" was given the type:
898+
899+
# type: dynamic()
900+
# from: types_test.ex:LINE
901+
x
902+
903+
hint: when defining the variable "x", you must also pattern match on "%Date{}". Given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of:
904+
905+
user = some_fun()
906+
%User{user | name: "John Doe"}
907+
908+
it is enough to write:
909+
910+
%User{} = user = some_fun()
911+
%{user | name: "John Doe"}
912+
"""
913+
914+
# When we don't know the type of capture
915+
assert typeerror!([], &%Date{&1 | day: 31}) =~ ~l"""
916+
a struct for Date is expected on struct update:
917+
918+
%Date{&1 | day: 31}
919+
920+
but got type:
921+
922+
dynamic()
923+
924+
where "capture" was given the type:
925+
926+
# type: dynamic()
927+
# from: types_test.ex:LINE
928+
&1
929+
930+
hint: instead of using &1, you must define an anonymous function, define a variable and pattern match on "%Date{}"\
931+
"""
932+
933+
# When we don't know the type of expression
934+
assert typeerror!([], %Date{SomeMod.fun() | day: 31}) =~ """
935+
a struct for Date is expected on struct update:
936+
937+
%Date{SomeMod.fun() | day: 31}
938+
939+
but got type:
940+
941+
dynamic()
942+
943+
hint: you must assign "SomeMod.fun()" to variable and pattern match on "%Date{}".\
944+
"""
945+
end
946+
874947
test "updating to open maps" do
875948
assert typecheck!(
876949
[key],

0 commit comments

Comments
 (0)