danielberkompas

danielberkompas

I have a problem. It’s very common to have nested structs when you deal with Ecto and associations. For example, a Person can have a City, which has a name.

The problem is, city can sometimes be nil. In that case, this code will raise an error. (“nil doesn’t respond to :name”) I want something like Ruby try here.

person.try(:city).try(:name)

Elixir has a built-in thing like this, called get_in:

get_in map, [:nonexistent, :keys]
# => nil

However, it only works with maps, not structs. The documentation says it’s specifically ​_not_​ supposed to work with structs.

So, I have a proposal. We make a module with a functional API like this:

maybe(person, [:city, :name])

And a macro to pretty things up a bit:

maybe(person.city.name)

If any element in the chain returns nil, the expression returns nil.

Here’s my sample implementation:

defmodule Maybe do
  defmacro maybe(ast) do
    [variable|keys] = extract_keys(ast)
​
    quote do
      maybe(var!(unquote(variable)), unquote(keys))
    end
  end
​
  def maybe(nil, _keys), do: nil
  def maybe(val, []), do: val
  def maybe(map, [h|t]) do
    maybe(Map.get(map, h), t)
  end
​
  defp extract_keys(ast, keys \\ [])
  defp extract_keys([], keys), do: keys
  defp extract_keys({{:., _, args}, _, _}, keys) do
    extract_keys(args, keys)
  end
  defp extract_keys([{{:., _, args}, _, _}|t], keys) do
    keys = keys ++ extract_keys(args)
    extract_keys(t, keys)
  end
  defp extract_keys([{:., _, args}|t], keys) do
    keys = keys ++ extract_keys(args)
    extract_keys(t, keys)
  end
  defp extract_keys([key|t], keys) do
    keys = keys ++ [key]
    extract_keys(t, keys)
  end
end

And example usage:

import Maybe

defmodule Person do
  defstruct city: nil
end
	
defmodule City do
  defstruct name: nil
end
	
person = %Person{city: %City{name: "Portland"}}
maybe(person.city.name) # => "Portland"
maybe(person.nonexistent.name) # => nil

Is this a good idea? Should it be named something else? Is there something in the standard library that I missed?

Showing Posts 1 to 10

josevalim

josevalim

Creator of Elixir

Remember you can use pattern matching:

case person do
  %{city: %{name: name}} -> name
  %{} -> default
end
NobbZ

NobbZ

I’m always confused when matching on %{} (or #{} in erlang)… My intuition does tell me we are matching on an empty map (as we were when matching on []), but in reality it is like anything when is_map(anything).

Is there a certain reason why pattern matching on %{} and [] is treatened differently?

josevalim

josevalim

Creator of Elixir

Is there a certain reason why pattern matching on %{} and is treatened differently?

They are different structures. Maps were designed to always map on a subset, therefore the empty map ends up matching all maps. Otherwise, imagine how useless pattern matching on maps would be if every time I wanted to take a key from the map, I had to match on all keys. The cases where I would want to do a full matching are rather rare (and can always be achieved with map_size).

12
Post #3
NobbZ

NobbZ

This makes absolutely sense, thank you for your answer.

jamonholmgren

jamonholmgren

Pattern matching does work, but is quite verbose compared to what @danielberkompas is suggesting.

Qqwy

Qqwy

TypeCheck Core Team

I agree with @jamonholmgren. @danielberkompas 's solution seems like a good way to concisely read data.

Of course, the ruby try() construct can sometimes be considered a code smell, as we’re actually breaking the Law of Demeter here, and a better solution (But often not worth the hassle/extra abstraction) would be to create a proper null-object.
But… I’m getting sidetracked.

I think that Maybe.maybe(some.field.lookup) is a very reasonable way to solve this problem. When using it, a developer explicitly acknowledges that the answer might be nil at any depth of the field lookup.

Oliver

Oliver

I see applications for this, definitely. Especially if not only considering structs.

But… doesn’t this actually do what you want, @danielberkompas ?

person |> Map.get(:city, %{}) |> Map.get(:name)

Note the use of the empty map as default. The Map.get/3 works on any struct or map as far as I can tell and returns nil or default if the value is not present. Returning by default the empty map makes it save to string these together, and the last one can simply return nil (the default anyway).

danielberkompas

danielberkompas OP

@Oliver, yes, Map.get does do what I want. I actually use it here to provide the functional API for Maybe:

def maybe(nil, _keys), do: nil
def maybe(val, []), do: val
def maybe(map, [h|t]) do
  maybe(Map.get(map, h), t)
end

You can call it like so: maybe(map, [:first, :second, :third]). I think it’s a little more convenient than doing a pipeline.

The maybe macro just converts calls like this: maybe(map.first.second.third) into functional calls for maybe(may, [:first, :second, :third]).

Edit: Since the Maybe module is so small, I decided not to release a hex package for it. If you’re interested, you can find it here: Add Maybe by danielberkompas · Pull Request #14 · infinitered/phoenix_base · GitHub

silverdr

silverdr

Is this still true as of today?

NobbZ

NobbZ

If a structs module implements the Access behaviour, then you can use it with the lense like accessors.

Though a 1:1 mapping to struct keys is often not really what you want for structs. Eg. for a set you might have a couple of struct keys required for the implementation, but each on its own doesn’t have any value. Instead you use Access behaviour to make a lookup in the set and either return nil for inexisting items or the item itself if it is in the set. (This is just an example)

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kpanic
Hi everyone, I am toying with the idea of building a “match maker” for giving personal help to people that wants to start coding. I sta...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
asweet-confluent
I recently noticed that Elixir’s Logger defaults its primary log level to :debug when no :logger, :level application configuration is pre...
New
apz
I’m new to elixir and just tried to install the elixirLS extension for VScode(ium) and it is throwing some errors that I would like help ...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews