Thanks for sharing the proposal! In my opinion, dealing only with maps
is too limiting. JSON is being used as an example, but traversing lists nested inside keys are very common in JSON and will require even more boilerplate than keys, and this whole subset of problems was declared as out of scope.
It is also worth saying that the ambiguity in lenses/access is not an intrinsic property of those solutions but an implementation choice. For example, in Elixir, map[:a][:b]
is for key-value pairs only, and trying to replace the key a
in [{a, 1, 2}]
will raise.
To be more concrete, a general solution could be to specify the operation you want to perform along side each key. For example, if you want to support maps and tuples, you could do:
1> access:get([{key, a}, {element, 2}], #{a => {1, 2, 3}}).
2
In this case, there is zero ambiguity: key
works with maps, element
works with tuples, and anything else would raise. This would allow you to add as many operations as you want, that can be arbitrarily nested, such as all
that could traverse lists:
1> access:get([{key, a}, all, {element, 2}], #{a => [{1, 2, 3}, {4, 5, 6}]}).
[2, 5]
This starts to resemble XPath and similar, where you have a richer API to traverse and update data structures. The downsides of this approach are two:
- If you only want to access map keys, wrapping everything in a
{key, ...}
can become verbose
- All of the operations must be defined upfront. Users don’t have the ability to add their own traversals
What lenses/access tell you is that there is a generic API we can define for anyone to apply any operation they want on the data type of their choice. Protocols are not a requirement and can be skipped altogether. So here is another possible solution to the problem:
-
If you pass any value, it is assume it is a map key, so you can access a nested map as: access:get([foo, bar], Map)
. This provides convenience for the most common use cases.
-
However, if the value is a 3-arity function, then said function must implement a contract so you can traverse any data structure.
Here is an implementation of the proposal above:
-module(access).
-export([get/2, key/1, all/0]).
% Main get function that handles map traversal with a list of keys
% as well as custom selectors
get([Key | Keys], Data) when is_function(Key, 3) ->
Key(get, Data, fun(Value) -> get(Keys, Value) end);
get([Key | Keys], Data) when is_map(Data) ->
get(Keys, maps:get(Key, Data));
get([], Data) ->
Data.
% Creates a selector that gets a specific key
key(Key) ->
fun(get, Map, Next) when is_map(Map) ->
Next(maps:get(Key, Map))
end.
% Creates a selector that traverses a list
all() ->
fun(get, List, Next) when is_list(List) ->
lists:map(Next, List)
end.
And now I can use it as:
6> c(access).
{ok,access}
7> access:get(
[languages, access:all(), name],
#{languages => [#{name => erlang}, #{name => elixir}]}
).
[erlang,elixir]
Notice how we can traverse maps, using bare keys, and then use selectors to traverse any data structure. In this case we used access:all/0
for lists, but you could add one for tuples, another for proplists, etc. You can create any selector you want, strict or relaxed, and all you have to do is to define a function that expects certain arguments (in Elixir, the selector has to support two operations, get
and get_and_update
).
The only ambiguity in the above is that, if you are storing a 3-arity function as a map key, you need to wrap said key in the access:key/1
selector, but this is extremely rare in practice, and a price more than worth paying in my opinion.
In any case, I am not advocating for this solution in particular, I just want to point out that the ambiguity is not intrinsic and there are many decisions that could be made to have a rich API that goes beyond maps (and also in very few lines of code).