Clojure Don’ts: Thread as

A brief addendum to my last post about Clojure’s threading macros.

As I was saying …

I said you could use as-> to work around the placement of an argument that doesn’t quite fit into a larger pattern of ->. My example was:

(-> results
    :matches
    (as-> matches (filter winning-match? matches))
    (nth 3)
    :scores
    (get "total_points"))

This is not a good example, because filter is a lazy sequence function that should more properly be used with ->>. And I warned explicitly against mixing -> and ->>.

Here’s a better example.

Say you have three functions, each taking a “context map” as their first argument and returning a similar map:

(defn one [context ...] ...)
(defn two [context ...] ...)
(defn three [context ...] ...)

Those functions can be chained together neatly with ->:

(-> context
    (one ...)
    (two ...)
    (three ...))

Now say you need to insert a call to another function that doesn’t quite follow the same pattern, maybe from a library whose source you do not control:

(defn irritating [... context ...] ...)

Here’s where the as-> macro really shows its purpose. You can slip in a call to irritating without having to change the structure of the ->.

(-> context
    (one ...)
    (two ...)
    (as-> ctx (irritating ... ctx ...))  ; OK
    (three ...))

That’s a good use case for as->, but I would tread cautiously even here. as-> subverts the usual pattern of -> and, as such, should be used only in exceptional situations where the alternative is worse.

In this example, I would much rather rewrite the irritating function to remove the inconsistency. If that’s not impossible, as in the third-party library case, and I had to use the function frequently, I would probably wrap it in my own function that has the arguments in an order consistent with ->.

The design of as->

In the days before version 1.5, Clojure users often clamored for something like as->. Many “utility” libraries provided their own version, usually looking something like this:

(defmacro -$> [form & more]
  (if (seq more)
    `(let [~'$ ~form]
       (-$> ~@more))
    form))

This is an anaphoric macro, because it “captures” the local symbol $ (the anaphor) and binds it to a value. It’s used like this:

(-$> {:a 1}
     (assoc $ :b 2)
     (+ (:a $) (:b $))
     (* $ 2))
;;=> 6

I never liked these macros, because they practically encourage violations of my expectation for -> that the “threaded” value stay the same “type” throughout the expression.

The as-> macro is different in a subtle but important way. Not only does it force you to give a real name to the “threaded” value, it places that name second in its parameters. To me, this clearly indicates that it is meant to be used in combination with ->.

(-> ...
    (as->   name ...)
    ;;    ^ the "threaded value" arrives here
    ...)

Outside of ->, the arguments to as-> appear in the order value name rather than name value, making it unlike anything else in Clojure.

Don’t use as-> by itself

Therefore, I can confidently say one should never use as-> by itself, as in:

;; Bad
(as-> (initial-value) it
      (one it ...)
      (two it ...)
      (irritating ... it ...)
      (three it ...))

The placement of the initial arguments is “backwards,” and the named symbol doesn’t communicate enough about its meaning throughout the expression. This is only marginally better than -$>, and often abused in the same way.

As in my last post, the response is the same: Either refactor the code to use consistent parameter placement, or just use let.