Concise nil safe way to access field on nested struct?

If I have nested structures, and I want to fetch a field from the a nested structure, but handle a nil case, is there a succinct way to do that? Lets say I have a Project struct which has a client field, which is itself a struct that has field name:

project = %Project{}
name = project.client.name

This works, but if client is nil we get an error. If these were maps I could do

name = get_in(project, [:client, :name]) || ""

Now we get the name if it exists, or we have an empty string. This does not work with structs because they don’t implement access. Is there a similarly concise solution for structs?

Hmm, pattern matching comes to mind…

For example, pattern matching via a case statement:

project = %Project{...}

name = case project do
  %{client: %{name: name}} -> name
  _ -> ""
end

# as one liners
name = case project, do: (%{client: %{name: name}} -> name; _ -> "")
name = case project do %{client: %{name: name}} -> name; _ -> "" end

Or alternatively, pattern matching via function heads:

project = %Project{...}
name = get_name(project)

defp get_name(%{client: %{name: name}}, do: name
defp get_name(_project), do: ""
1 Like

You can also use a combination of the Kernel.get_in/2 function and the Access.key/2:
name = get_in(project, [Access.key(:client), Access.key(:name)]) || “”

But I think pattern matching is always best first option

It may be heretical but for all of my ecto structs at least we just went ahead and implemented the Access behavior functions so that we can just use access normally. The ergonomics are really nice.

3 Likes

I do the same. There are a couple of projects on hex that you can use: accessible and another I can’t remember, or roll your own.

1 Like

You can use Access.key/2 (or Access.key!/1):

name = get_in(project, [Access.key(:client), Access.key(:name)])
1 Like

Is this then an exception to the following note from the Acess module?

Attention! While the access syntax is allowed in maps via map[key] , if your map is made of predefined atom keys, you should prefer to access those atom keys with map.key instead of map[key] , as map.key will raise if the key is missing (which is not supposed to happen if the keys are predefined). Similarly, since structs are maps and structs have predefined keys, they only allow the struct.key syntax and they do not allow the struct[key] access syntax. See the Map module for more information.

Any unforeseen downsides of your approach?

Yeah I think that moduledoc is perhaps a bit too strongly worded, because it isn’t that structs aren’t allowed to use that syntax, it’s just that it won’t work by default.

Not really, it’s worked great. In fact in particular, it works rather well with how Ecto handles associations that aren’t loaded yet. Eg:

shipment.origin_location[:customer][:name]

If you forget to Repo.preload(shipment, [origin_location: :customer]) then you get:

** (UndefinedFunctionError) function Ecto.Association.NotLoaded.fetch/2 is undefined (Ecto.Association.NotLoaded does not implement the Access behaviour.

Which is great! You didn’t load the association and so instead of treating that as just nil it errors, indicating that you need to actually load it to determine if it’s there or not.

2 Likes

How was the behavior implemented code-wise? I want to do this on our structs

Any time nested data access is concerned I’m partial to pathex by @hst337

I find this lib to be very convenient and well documented.

1 Like

Thanks so much Ben! Really appreciate your reply. And on top of that, your approach is even better! Thanks for the example.

I think it suffices to add @behaviour Access to your struct and implement the 3 @callbacks. If you only need a read access, it’s enough to merely implement

  def fetch(struct, key) do
    {:ok, Map.get(struct, key)}
  end

in your struct (without needing to also add @behaviour Access to it). Though I think you then can’t get any compiler support via @impl Access annotation on the fetch/2 function. (Somebody correct me please if otherwise.)

1 Like

It is impossible to implement it for already-defined modules without patching dependencies