Skip to content

docs: enhance non-assertive map access anti-pattern documentation #14501

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
53 changes: 49 additions & 4 deletions lib/elixir/pages/anti-patterns/code-anti-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ When a key is optional, the `map[:key]` notation must be used instead. This way,

When you use `map[:key]` to access a key that always exists in the map, you are making the code less clear for developers and for the compiler, as they now need to work with the assumption the key may not be there. This mismatch may also make it harder to track certain bugs. If the key is unexpectedly missing, you will have a `nil` value propagate through the system, instead of raising on map access.

##### Table: Comparison of map access notations

| Access notation | Key exists | Key doesn't exist | Use case |
| --------------- | ---------- | ----------------- | -------- |
| `map.key` | Returns the value | Raises `KeyError` | Structs and maps with known atom keys |
| `map[:key]` | Returns the value | Returns `nil` | Any `Access`-based data structure, optional keys |

#### Example

The function `plot/1` tries to draw a graphic to represent the position of a point in a Cartesian plane. This function receives a parameter of `Map` type with the point attributes, which can be a point of a 2D or 3D Cartesian coordinate system. This function uses dynamic access to retrieve values for the map keys:
Expand Down Expand Up @@ -357,7 +364,19 @@ iex> Graphics.plot(bad_point)
{nil, 3, 4}
```

The behavior above is unexpected because our function should not work with points without a `:x` key. This leads to subtle bugs, as we may now pass `nil` to another function, instead of raising early on.
The behavior above is unexpected because our function should not work with points without a `:x` key. This leads to subtle bugs, as we may now pass `nil` to another function, instead of raising early on, as shown next:

```iex
iex> point_without_x = %{y: 10}
%{y: 10}
iex> {x, y, _} = Graphics.plot(point_without_x)
{nil, 10, nil}
iex> distance_from_origin = :math.sqrt(x * x + y * y)
** (ArithmeticError) bad argument in arithmetic expression
:erlang.*(nil, nil)
```

The error above occurs later in the code because `nil` (from missing `:x`) is invalid for arithmetic operations, making it harder to identify the original issue.

#### Refactoring

Expand All @@ -380,9 +399,15 @@ iex> Graphics.plot(bad_point)
graphic.ex:4: Graphics.plot/1
```

Overall, the usage of `map.key` and `map[:key]` encode important information about your data structure, allowing developers to be clear about their intent. See both `Map` and `Access` module documentation for more information and examples.
This is beneficial because:

1. It makes your expectations clear to others reading the code
2. It fails fast when required data is missing
3. It allows the compiler to provide warnings when accessing non-existent fields, particularly in compile-time structures like structs

An alternative to refactor this anti-pattern is to use pattern matching, defining explicit clauses for 2d vs 3d points:
Overall, the usage of `map.key` and `map[:key]` encode important information about your data structure, allowing developers to be clear about their intent. The `Access` module documentation also provides useful reference on this topic. You can also consider the `Map` module when working with maps of any keys, which contains functions for fetching keys (with or without default values), updating and removing keys, traversals, and more.

An alternative to refactor this anti-pattern is to use pattern matching, defining explicit clauses for 2D vs 3D points:

```elixir
defmodule Graphics do
Expand All @@ -400,7 +425,19 @@ defmodule Graphics do
end
```

Pattern-matching is specially useful when matching over multiple keys as well as on the values themselves at once.
Pattern-matching is specially useful when matching over multiple keys as well as on the values themselves at once. In the example above, the code will not only extract the values but also verify that the required keys exist. If we try to call `plot/1` with a map that doesn't have the required keys, we'll get a `FunctionClauseError`:

```elixir
iex> incomplete_point = %{x: 5}
%{x: 5}
iex> Graphics.plot(incomplete_point)
** (FunctionClauseError) no function clause matching in Graphics.plot/1

The following arguments were given to Graphics.plot/1:

# 1
%{x: 5}
```

Another option is to use structs. By default, structs only support static access to its fields. In such scenarios, you may consider defining structs for both 2D and 3D points:

Expand All @@ -413,6 +450,14 @@ end

Generally speaking, structs are useful when sharing data structures across modules, at the cost of adding a compile time dependency between these modules. If module `A` uses a struct defined in module `B`, `A` must be recompiled if the fields in the struct `B` change.

In summary, Elixir provides several ways to access map values, each with different behaviors:

1. **Static access** (`map.key`): Fails fast when keys are missing, ideal for structs and maps with known atom keys
2. **Dynamic access** (`map[:key]`): Works with any `Access` data structure, suitable for optional fields, returns nil for missing keys
3. **Pattern matching**: Provides a powerful way to both extract values and ensure required map/struct keys exist in one operation

Choosing the right approach depends if the keys are known upfront or not. Static access and pattern matching are mostly equivalent (although pattern matching allows you to match on multiple keys at once, including matching on the struct name).

#### Additional remarks

This anti-pattern was formerly known as [Accessing non-existent map/struct fields](https://github.com/lucasvegi/Elixir-Code-Smells#accessing-non-existent-mapstruct-fields).
Expand Down