Welcome! Please see the About page for a little more info on how this works.

+4 votes
in Clojure by
edited by

In Clojure/conj talks Effective Programs and Maybe Not, Rich Hickey pointed that if we don't know something we should leave it out, i.e. we should not have keyword-nil (spec/nilable) pairs in maps.

What is the best way to deal with situations like this:

(defn person
  [line]
  {:person/name   (name   line)   ;; req
   :person/height (height line)}) ;; opt, height may return nil

I came up with these solutions:

Add additional arity to 'opt' fns: keyword and map, so they can conditionally assoc. Compose everything via threading macro. Applicable only if you 'own' those fns.

(defn person
  [line]
  (->> {:person/name (name line)}
       (height line :person/height)))

Check on the call-site and assoc if some. It's really ugly imo, imagine multiple optionals - how to avoid nesting if-lets?"

(defn person
  [line]
  (let [ret {:person/name (name line)}]
    (if-let [height (height line)]
      (assoc ret :person/height height)
      ret)))

4 Answers

+7 votes
by
selected by
 
Best answer

First, I would ask what in height triggers the condition. Given that, cond-> comes into play and that's my favorite tool for this:

(defn person
  [line]
  (cond-> {:person/name (name line)}  ;; non-optional stuff goes here
    (height? line) (assoc :height (height line))
    (weight? line) (assoc :weight (weight line))
    ;; and so on for each optional thing
  ))

An example in real code (several other examples in that project).

Another good option is merge which is a good tool for squishing multiple partial (and maybe empty) things together. If height returned either nil or a map with height in it then it looks like:

(defn person
  [line]
  (merge {:person/name (name line)}
         (height line)  ;; nil or {:height val}
         ;; and so on for each optional thing or sets of things
  ))
by
For very regular cases where I'm doing the identical thing on many attributes, I will sometimes macro over the first example to remove all the duplication. That's rarely generic enough that I reuse it but it can DRY up that example if needed (personally the duplication doesn't bother me as it's very clear what it's doing).
by
Considering this whole issue of nil in a hash map, after hearing Rich's talk, is what led me to write https://corfield.org/blog/2018/12/06/null-nilable-optionality/ and how seancorfield/next.jdbc arrived at the default of keeping SQL null values in hash maps but also offers next.jdbc.optional/as-maps as a way to turn SQL result sets into hash maps that OMIT SQL null values.
by
edited by
Basically all person attributes are concatenated into single line. I use subs to take height part, when-not str/blank? return some conversion.  cond-> will do, I'll just gather maybe-nil returns inside let.
+1 vote
by

Another option I use sometimes is simply to create the map with nils, and then remove them out right after:

(->> {:a 10
      :b nil}
 (remove (comp nil? val))
 (into {}))
by
I think it's best (for both performance and clarity) to avoid putting nils in the collection in the first place.
by
This is useful when dealing with library functions that produce map(s) with nils e.g. for clojure.java.jdbc/query (new one, 'next-jdbc', does it better though).
0 votes
by
edited by

One solution is to use an assoc variant like assoc-when from the plumatic/plumbing library, but I imagine there are similar functions in other utility libs.

https://github.com/plumatic/plumbing/blob/master/src/plumbing/core.cljx#L147

by
this `assoc-when` will throw away `false`s, not just `nil`s
0 votes
by

This pattern is common and is part of various utility libraries. A simple, 0 dependency solution:

(defn ?assoc
  ([m k v] (if (nil? v) m (assoc m k v)))
  ([m k v & kvs]
  (assert (even? (count kvs)))
  (let [it (.iterator ^Iterable kvs)]
    (loop [m (?assoc m k v)]
      (if (.hasNext it)
        (recur (?assoc m (.next it) (.next it)))
        m)))))
...