Skip to content

Commit 18c81d6

Browse files
committed
Add type checking for Map.fetch/2 and improve docs
1 parent 7fbf48c commit 18c81d6

File tree

3 files changed

+179
-41
lines changed

3 files changed

+179
-41
lines changed

lib/elixir/lib/map.ex

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,13 @@ defmodule Map do
287287
@doc """
288288
Fetches the value for a specific `key` in the given `map`.
289289
290-
If `map` contains the given `key` then its value is returned in the shape of `{:ok, value}`.
291-
If `map` doesn't contain `key`, `:error` is returned.
290+
If `map` contains the given `key` then its value is returned
291+
in the shape of `{:ok, value}`. If `map` doesn't contain `key`,
292+
`:error` is returned.
293+
294+
If the type system can verify `:error` is always returned
295+
(which means key is never available in the map), it will emit
296+
an error.
292297
293298
Inlined by the compiler.
294299
@@ -307,8 +312,11 @@ defmodule Map do
307312
Fetches the value for a specific `key` in the given `map`, erroring out if
308313
`map` doesn't contain `key`.
309314
310-
If `map` contains `key`, the corresponding value is returned. If
311-
`map` doesn't contain `key`, a `KeyError` exception is raised.
315+
The exclamation mark (`!`) implies this function can raise a `KeyError`
316+
exception at runtime if `map` doesn't contain `key`. If the type system
317+
can verify this function will always raise (which means the key is never
318+
available), then it will emit a warning at compile-time. See the "Type
319+
checking" section below.
312320
313321
Inlined by the compiler.
314322
@@ -318,10 +326,50 @@ defmodule Map do
318326
1
319327
320328
When the key is missing, an exception is raised:
321-
329+
322330
Map.fetch!(%{a: 1}, :b)
323331
** (KeyError) key :b not found in: %{a: 1}
324332
333+
## Type checking
334+
335+
The compiler will emit a warning if it can verify that
336+
none of the keys given are available in the map.
337+
338+
When the key is an atom, because only single key is given,
339+
a warning will be emitted in case the type system proves
340+
the key is not present.
341+
342+
However, this behaviour matters when the type of the key
343+
represents multiple values. For example:
344+
345+
key = returns_foo_or_bar() #=> :foo or :bar
346+
Map.fetch!(%{foo: 123}, key)
347+
348+
Although the key can be `:foo` or `:bar`, there is no
349+
warning emitted, as `:foo` will succeed. This is by design:
350+
the exclamation mark in Elixir denotes precisely that a
351+
runtime exception may be raised.
352+
353+
In case you are looking up multiple keys and you don't know
354+
if they may be present, you can use `Map.fetch/2` instead
355+
and deal with the error case accordingly:
356+
357+
case Map.fetch(%{foo: 123}, key) do
358+
{:ok, value} -> ...
359+
:error -> ...
360+
end
361+
362+
Both `Map.fetch!/2` and `Map.fetch/2` will emit a warning if
363+
it proves that both `:foo` or `:bar` are absent in the map.
364+
365+
Alternatively, if you want to statically prove that all of keys
366+
are in the map, you can match on the possible values and access
367+
them directly:
368+
369+
case returns_foo_or_bar() do
370+
:foo -> map.foo
371+
:bar -> map.bar
372+
end
325373
"""
326374
@spec fetch!(map, key) :: value
327375
def fetch!(map, key) do

lib/elixir/lib/module/types/apply.ex

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ defmodule Module.Types.Apply do
245245

246246
## Map
247247
{:maps, :from_keys, [{[list(term()), term()], open_map()}]},
248+
{:maps, :find,
249+
[{[term(), open_map()], tuple([atom([:ok]), term()]) |> union(atom([:error]))}]},
248250
{:maps, :get, [{[term(), open_map()], term()}]},
249251
{:maps, :is_key, [{[term(), open_map()], boolean()}]},
250252
{:maps, :keys, [{[open_map()], dynamic(list(term()))}]},
@@ -335,6 +337,11 @@ defmodule Module.Types.Apply do
335337
{{:compare, name, check?}, [term(), term()], context}
336338
end
337339

340+
def remote_domain(:maps, :get, [key, _], expected, _meta, _stack, context) when is_atom(key) do
341+
domain = [term(), open_map([{key, expected}])]
342+
{{:strong, nil, [{domain, term()}]}, domain, context}
343+
end
344+
338345
def remote_domain(mod, fun, args, expected, meta, stack, context) do
339346
arity = length(args)
340347

@@ -425,6 +432,20 @@ defmodule Module.Types.Apply do
425432
end
426433
end
427434

435+
defp remote_apply(:maps, :find, _info, [key, map] = args_types, stack) do
436+
case map_get(map, key) do
437+
{_, value} ->
438+
result = tuple([atom([:ok]), value]) |> union(atom([:error]))
439+
{:ok, return(result, args_types, stack)}
440+
441+
:badmap ->
442+
{:error, badremote(:maps, :find, 2)}
443+
444+
:error ->
445+
{:error, {:badkeydomain, map, key, atom([:error])}}
446+
end
447+
end
448+
428449
defp remote_apply(:maps, :get, _info, [key, map] = args_types, stack) do
429450
case map_get(map, key) do
430451
{_, value} ->
@@ -434,7 +455,7 @@ defmodule Module.Types.Apply do
434455
{:error, badremote(:maps, :get, 2)}
435456

436457
:error ->
437-
{:error, {:badkeydomain, map, key, []}}
458+
{:error, {:badkeydomain, map, key, nil}}
438459
end
439460
end
440461

@@ -458,7 +479,7 @@ defmodule Module.Types.Apply do
458479
{:error, badremote(:maps, :take, 2)}
459480

460481
{:error, _errors} ->
461-
{:ok, return(atom([:error]), args_types, stack)}
482+
{:error, {:badkeydomain, map, key, atom([:error])}}
462483
end
463484
end
464485

@@ -1073,17 +1094,11 @@ defmodule Module.Types.Apply do
10731094
}
10741095
end
10751096

1076-
def format_diagnostic({{:badkeydomain, map, key, errors}, _args_types, mfac, expr, context}) do
1097+
def format_diagnostic({{:badkeydomain, map, key, error}, _args_types, mfac, expr, context}) do
10771098
{mod, fun, arity, _converter} = mfac
10781099
traces = collect_traces(expr, context)
10791100
mfa_or_fa = if mod, do: Exception.format_mfa(mod, fun, arity), else: "#{fun}/#{arity}"
10801101

1081-
error_key =
1082-
Enum.reduce(errors, none(), fn
1083-
{:badkey, key}, acc -> union(atom([key]), acc)
1084-
{:baddomain, domain}, acc -> union(domain, acc)
1085-
end)
1086-
10871102
%{
10881103
details: %{typing_traces: traces},
10891104
message:
@@ -1093,33 +1108,20 @@ defmodule Module.Types.Apply do
10931108
10941109
#{expr_to_string(expr) |> indent(4)}
10951110
1096-
""",
1097-
if errors == [] do
1098-
"""
1099-
the map:
1111+
the map:
11001112
1101-
#{to_quoted_string(map) |> indent(4)}
1113+
#{to_quoted_string(map) |> indent(4)}
11021114
1103-
does not have the given keys:
1115+
does not have all required keys:
11041116
1105-
#{to_quoted_string(key) |> indent(4)}
1117+
#{to_quoted_string(key) |> indent(4)}
11061118
1107-
"""
1119+
therefore this function will always #{if error do
1120+
"return #{to_quoted_string(error)}"
11081121
else
1109-
"""
1110-
expected the map:
1111-
1112-
#{to_quoted_string(map) |> indent(4)}
1113-
1114-
to have the keys:
1115-
1116-
#{to_quoted_string(key) |> indent(4)}
1117-
1118-
but the following keys are missing:
1119-
1120-
#{to_quoted_string(error_key) |> indent(4)}
1121-
"""
1122-
end,
1122+
"raise"
1123+
end}
1124+
""",
11231125
format_traces(traces)
11241126
])
11251127
}

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

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ defmodule Module.Types.MapTest do
1313

1414
describe ":maps.take/2" do
1515
test "checking" do
16-
assert typecheck!(:maps.take(:key, %{})) == atom([:error])
17-
1816
assert typecheck!(:maps.take(:key, %{key: 123})) |> equal?(tuple([integer(), empty_map()]))
1917

2018
assert typecheck!([x], :maps.take(:key, x))
@@ -41,7 +39,7 @@ defmodule Module.Types.MapTest do
4139
)
4240
)
4341

44-
assert typecheck!([x], :maps.take(integer(), x))
42+
assert typecheck!([x], :maps.take(123, x))
4543
|> equal?(
4644
union(
4745
dynamic(tuple([term(), open_map()])),
@@ -63,10 +61,98 @@ defmodule Module.Types.MapTest do
6361
test "errors" do
6462
assert typeerror!([x = []], :maps.take(:foo, x)) =~
6563
"incompatible types given to :maps.take/2"
64+
65+
assert typeerror!(:maps.take(:key, %{})) =~ """
66+
incompatible types given to :maps.take/2:
67+
68+
:maps.take(:key, %{})
69+
70+
the map:
71+
72+
empty_map()
73+
74+
does not have all required keys:
75+
76+
:key
77+
78+
therefore this function will always return :error
79+
"""
80+
end
81+
end
82+
83+
describe "Map.fetch/2" do
84+
test "checking" do
85+
assert typecheck!(Map.fetch(%{key: 123}, :key)) ==
86+
tuple([atom([:ok]), integer()]) |> union(atom([:error]))
87+
88+
assert typecheck!([x], Map.fetch(x, :key))
89+
|> equal?(dynamic(tuple([atom([:ok]), term()])) |> union(atom([:error])))
90+
91+
# If one of them succeeds, we are still fine!
92+
assert typecheck!(
93+
[condition?],
94+
Map.fetch(%{foo: 123}, if(condition?, do: :foo, else: :bar))
95+
) == tuple([atom([:ok]), integer()]) |> union(atom([:error]))
96+
97+
assert typecheck!([x], Map.fetch(x, 123))
98+
|> equal?(dynamic(tuple([atom([:ok]), term()])) |> union(atom([:error])))
99+
end
100+
101+
test "inference" do
102+
assert typecheck!(
103+
[x],
104+
(
105+
_ = Map.fetch(x, :key)
106+
x
107+
)
108+
) == dynamic(open_map())
109+
end
110+
111+
test "errors" do
112+
assert typeerror!(Map.fetch(%{}, :foo)) =~
113+
"""
114+
incompatible types given to Map.fetch/2:
115+
116+
Map.fetch(%{}, :foo)
117+
118+
the map:
119+
120+
empty_map()
121+
122+
does not have all required keys:
123+
124+
:foo
125+
126+
therefore this function will always return :error
127+
"""
66128
end
67129
end
68130

69131
describe "Map.fetch!/2" do
132+
test "checking" do
133+
assert typecheck!(Map.fetch!(%{key: 123}, :key)) == integer()
134+
135+
assert typecheck!([x], Map.fetch!(x, :key)) == dynamic()
136+
137+
# If one of them succeeds, we are still fine!
138+
assert typecheck!(
139+
[condition?],
140+
Map.fetch!(%{foo: 123}, if(condition?, do: :foo, else: :bar))
141+
) == integer()
142+
143+
assert typecheck!([x], Map.fetch!(x, 123)) |> equal?(dynamic())
144+
end
145+
146+
test "inference" do
147+
assert typecheck!(
148+
[x],
149+
(
150+
y = Integer.to_string(Map.fetch!(x, :key))
151+
{x, y}
152+
)
153+
) == dynamic(tuple([open_map(key: integer()), binary()]))
154+
end
155+
70156
test "errors" do
71157
assert typeerror!(Map.fetch!(%{}, :foo)) =~
72158
"""
@@ -78,10 +164,11 @@ defmodule Module.Types.MapTest do
78164
79165
empty_map()
80166
81-
does not have the given keys:
167+
does not have all required keys:
82168
83169
:foo
84170
171+
therefore this function will always raise
85172
"""
86173

87174
assert typeerror!(Map.fetch!(%{}, 123)) =~
@@ -94,10 +181,11 @@ defmodule Module.Types.MapTest do
94181
95182
empty_map()
96183
97-
does not have the given keys:
184+
does not have all required keys:
98185
99186
integer()
100187
188+
therefore this function will always raise
101189
"""
102190
end
103191
end

0 commit comments

Comments
 (0)