plumatic / plumbing

Prismatic's Clojure(Script) utility belt

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Letk destructuring of an optional renamed key with schema validation

lyderichti59 opened this issue · comments

Hi,
I've read the docs here : https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax
and here : http://plumatic.github.io/plumbing/plumbing.fnk.schema.html
and I don't understand how to go with some destructuring scenarios.

Basically, there is "vanilla" destructuring (just bind a symbol to a key in a map) + 3 main extra features being :

  1. Make a binding optional by providing a fallback value
  2. Make a binding's type/schema explicit
  3. Rename a binding (give it another name than the key)

And I struggle to combine these extra features.

Scenario A : vanilla => destructuring a plain map

(letk [[foo] {:foo 42}] foo) 
=> 42

Result : ✅

Scenario B : renaming only => destructure the key named :foo and bind to bar

(letk [ [[:foo :as bar]] {:foo 42}] bar) 
=> 42

Result : ✅

Scenario C : fallback only => destructure a key named :bar and fallback to 0 when the key is missing

(letk [ [{bar 0}] {:foo 42}] bar) 
=> 0

Result : ✅

Scenario D : schema only => destructure a key named :foo and add a schema hint

(letk [[foo :- schema.core/Number] {:foo 42}] foo)
=> 42

Result : ✅

Alright, using at most 1 extra feature worked.
Now let's try by combining 2 or 3 extra features.

Scenario E : schema + fallback => destructure a type-hinted missing key named :bar and fallback to 0

(letk [[{bar :- schema.core/Number 0}] {:foo 42}] bar)
=> 42

Result : ✅

Where I'm stuck

However, I haven't found a way to combine :

  • schema + renaming
  • renaming + fallback
  • schema + fallback + renaming

Are these combinations supported ? If not, what would it take to support them ?

PS : Classic let bindings support renaming + fallback, which I use often, hence the current issue :

(let [{baz :bar :or {baz 0}} {:foo 42}] baz)
=> 0

I don't remember if these are supported currently, I'm not sure if I thought of these combinations (I mostly thought of "renaming" as a way to bind an entire map).

I think schema + renaming would be easy to support if we don't already.

For renaming + fallback, what would you expect the behavior to be? Would the fallback also be used for the other bindings?

i.e. does (letk [[:x a :as {b {:a 1}}] {}] a) crash or return 1?

Regarding renaming + fallback having sibling bindings as fallback, I'd reproduce the same behaviour as let (clojure.core).
i.e., we can use bindings however we like provided they have already been bound.

Example 1 : this works because b reuses a but a was already bound

(let [a 1, b (inc a)] b)
=> 2

Example 2 : this doesn't work because b is not bound yet when we use it to bind a

(let [a (inc b), b 2] b)
=> Syntax error compiling. Unable to resolve symbol `b` in this context

I'm not sure to understand your syntax proposal though.
I'm happy to see alternatives, but using an :or keyword might help.
Random example :

(letk [ [[:bar :or 777 :as baz]] {:foo 42}] baz) 
=> 777

Using :or & :as might be convenient with other combination as well.
schema + renaming would give :

(letk [[foo :- schema.core/Number :as bar] {:foo 42}] bar)
=> 42

schema + renaming + fallback

(letk [[:bar :- schema.core/Number :or 777 :as baz] {:foo 42}] baz)
=> 777

Please note I'm just brainstorming and I'm happy to see alternatives / what you have in mind.

But IMHO, deprecating the {binding fallback} and {binding :- schema fallback} patterns & relying only on vectors would avoid the cons of maps : being constrained to have pairs of symbols (which may force you to use nested structures (maps or vectors) for keys or values, just for the sake of having pairs)

With plain vectors, since you don't have any constraints on the vector's size (and can have an odd number of items in the vector) you could fairly easily pick any optional :- schema, :or fallback :as renaming pairs, with any combination of pairs you want
at the expense of a small verbosity (having :- :or and :as would be less concise than not having them), but with a great legibility improvement (probably subjective ?)

Examples for all combinations :
:foo => vanilla
[:foo :as bar] => renaming only
[:foo :- s/Number] => schema only
[:foo :or 3] => fallback only
[:foo :or 3 :as bar] => fallback + renaming
[:foo :- s/Number :as bar] => schema + renaming
[:foo :- s/Number :or 3] => schema + fallback
[:foo :- s/Number :or 3 :as bar] => schema + fallback + renaming

Thanks for the detailed response. I'll need to think about these suggestions a bit.

Off the top of my head, the first thing I notice is that in [:foo :or 3] nothing is bound. The original intent behind the nested vector syntax was that you'd use it primarily to further bind things out of a sub-map, and the ability to use that for aliasing was somewhat incidental. Of course, there's no reason we have to stick to that, but I think we'd need to write [:foo :as foo :or 3] which is a bit of a mouthful compared to {foo 3} IMO.

Good shout. I understand (and had not properly appreciated, I admit) that the nested vector syntax was first designed for sub-maps. It's great, and my suggestions above are somehow equivalent to extending these features (initially for sub-maps) to the top level maps too, in order to enable new use cases.

Regarding :foo (keyword) or foo (symbol), my thinking was to have the macro bind [:foo :or 3] to the foo symbol by default when there is no :as renaming. It's a rather opinionated solution that has the benefit of being "universal" (aka, in the examples above, you don't have to think about the syntax ("keyword or symbol?") depending on the context ("is there a renaming?"), you just always use a keyword as a first item). It also would have the benefit of being compatible with namespace keywords (the classic : (let [{:keys [:ns/foo :bar]} {:ns/foo 42, :bar 777}] [foo bar]) returns [42 777] because keywords (namespaced or not) are bound to symbols by default.

Alternatively, the syntax could bind a symbol in first position (when there is no renaming) (example : [foo :or 3], no keyword at first position here) and if there's any renaming (example [:foo :or 3 :as bar]). But I prefer the solution above. YMMV

Again, this is just brainstorming

Thanks, happy to continue brainstorming.

I personally think it's a smell to ever bind anything that isn't a symbol in the binding map (e.g., binding to the symbol form of a keyword). The potential for confusion when accidentally shadowing an existing binding or something is just too high.

I also think the asymmetry between (defnk [foo ...] ...) and (defnk [... [foo ...] ...] ...) (the first binds a key in the current map, whereas the second binds a symbol in the outer map) is very confusing.

On further reflection, I think the best route might be:

(letk [ [[:foo :as {bar 25}]] {}] bar) for combining a default with a rename. And for optional + schema, I guess metadata is always an option. Unfortunately the :- syntax just doesn't work well with the map-based default syntax (which predates the introduction of :- and schemas). We could consider switching away from maps, but that's a significant enough breaking change that I'd be pretty reluctant to do it, especially since you're the first person to bring up this gap.