Feb. 28, 2025, 3:02 p.m.

Extensible Macros

Curiosities by Tensegritics

by cgrand (🦋 🦣 𝕏)

Like many of us I have a long and well-documented record of love/hate relationship with macros.

Despite that, the cornerstone of cljd.flutter (the ClojureDart namespace for making Flutter more palatable) is a macro: f/widget. It’s a Flutter-specific threading macro, supercharged with 15+ directives.

Making the macro user-extensible had been on my mind for a long time, but a comment by Balint Erdos on Clojurian’s #clojuredart finally triggered the implementation (the redacted part is what matters).

image.png

Don’t get me wrong: it’s not because someone asked for extensibility that I jumped to implementation. It’s because in a moment of clarity I saw a good design for it: a better one that the ones I previously considered and one that could be implemented in minutes.

For the record: while searching the above message in Slack, I found a similar but older message (by Balint too) mentioning extensibility en passant and it didn't trigger anything at the time.

image.png

The change is almost trivial—just three extra lines and one line changed but it took me a long journey to come up with: it all starts 14 years ago while trying to define a “flatter cond”.

Ceci n'est pas une macro

By the way, Baptiste and I are always open to new challenges: from letting you pick our brains for a couple of hours, to handling a whole project or writing a prototype or a MVP. Get in touch!

Else we are working on ClojureDart or on our app Paktol (The positive spending tracker where money goes up!)—we are still dutifully dogfooding the upcoming feature which turns Paktol from a MVP to something way more complete and cohesive.

A Flatter Cond

It all started with me wanting to spice up conds with some :lets to avoid nesting.

(cond 
  (odd? a) 1
  :let [a (quot a 2)]
  (odd? a) 2
  :else 3)

It escalated quickly: a couple of months later I added :when and :when-let and devised a metadata-based extension mechanism to add more. Just in case 🤷‍♂️. (I don't remember why I didn't use multimethods.)

Mark Engelberg ran with the idea in better-cond and wisely skipped the extension system.

Private Macros and Brain Worms

Truth is: I rarely used any "better cond" library. When I needed to, I just added a "better cond" as a private macro to the namespace I was working on.

Yes, as a private macro—not even in a dreaded utils namespace.

I really believe private macros should be more liberally used:

  1. they can cut down boilerplate in a specific namespace in a very ad-hoc manner,

  2. because they are local to a namespace, there's no need to sweat them too much to make them worth general use (or wishful-reuse utils grade),

  3. their definition is colocated to their usage.

The very specific and one-off nature of private macros drives the apparition of two strains:

  • highly specific (and sometimes complex) macros that will never exist again
  • locally fit variations on simple macros (like "better" conds) whose core implementations are short enough that one reimplements them from namespaces to namespaces, from projects to projects. Always different, always similar.

There's evolutionary pressure on this later strain to stay short (easy on memory, easy on typing). This led me to stumble on nest-last <<- (aka else->>):

The little ugly macro that clojure pundits don’t want you to hear about :-)

(defmacro <<- [& forms] `(->> ~@(reverse forms)))

— Christophe Grand (@cgrand) July 10, 2020
(defmacro <<- [& forms] `(->> ~@(reverse forms)))

It's just ->> but applied right to left, so what's the point? Let's consider the expansion of (<<- (if a then-a) (let [some bindings]) (if b then-b) else):

(<<- (if a then-a) (let [some bindings]) (if b then-b) else)

(->> else (if b then-b) (let [some bindings]) (if a then-a))

(->> (if b then-b else) (let [some bindings]) (if a then-a))

(->> (let [some bindings] (if b then-b else)) (if a then-a))

(->> (if a then-a (let [some bindings] (if b then-b else))))

(if a then-a (let [some bindings] (if b then-b else)))

So in this case it acted as this extended cond form:

(cond a then-a :let [some bindings] b then-b :else else)
; vs
(<<- (if a then-a) (let [some bindings]) (if b then-b) else)

(Note that it's barely shorter than the equivalent <<- form above.)

What's great about <<- (besides its brevity and virality) is that it doesn't need to be extended: it syntactically composes other forms! You can use if-let, if-some, or... Anything. No need for extension.

It's the main takeaway of this journey: if you keep wanting to make a macro extensible, make it leverage syntactical composition instead!

Back to f/widget

At its core, f/widget is a threading (or nesting rather) macro. It allows to turn this typical nested Flutter expression:

(m/SizedBox .height 200
  .child (m/Center
           .child (m/Text "hello")))

into this:

(f/widget
  (m/SizedBox .height 200)
  m/Center
  (m/Text "hello"))

Additional features kept being added to f/widget: first the ability to thread using another named parameter, then many directives ranging from foundational (like :watch and :managed) to mundane (:height or :let — :let again!).

The internal and external pressure to add more directives is real, but one can't say yes to every suggestion.

Besides it's already somehow possible to "extend" it by leveraging the child-nesting! Let's say we want to add an alignment helper:

(defn align [[x y] .child]
  (m/Align .alignment (m/Alignment x y) .child child))

It can be used inside a f/widget but looks like a regular form, not like a directive.

(f/widget
  :let [msg "hello"]
  :height 200 ; built-in directive, same as (m/SizedBox .height 200)
  (align [0 0])
  (m/Text msg))

Somehow f/widget was already extensible but there was a line drawn in the sand between what's core (directives) and what's not (regular forms).

So recently I changed one small behavior: simple directives (non qualified keywords) are reserved for f/widget future growth, qualified keywords are for users. :ns/name form will just expand as (ns/name form).

Thus the previous snippet can now be written as:

(f/widget
  :let [msg "hello"]
  :height 200 ; built-in directive, same as (m/SizedBox .height 200)
  ::align [0 0]
  (m/Text msg))

It's a small change but brings symmetry: user code can provide helpers even very ad-hoc (private!) ones without being second class citizen in the system.

Routes Not Taken

In hindsight, the whole notion of directives could have been avoided by leveraging only the compositional nature of f/widget. Somewhere in the multiverse, on Earth-1337, one writes:

(f/widget-1337
  (f/let [msg "hello"])
  (f/height 200) ; built-in directive, same as (m/SizedBox .height 200)
  (align [0 0])
  (m/Text msg))

The f/let is a bit of an eyesore but it's the price to pay (unless adding a special case to this f/widget-1337 but then the whole point of being regular is defeated).

Anyway de gustibus non est disputandum and at this point removing directives from f/widget would be like removing them from for or doseq.

🤔 A syntactical composable take on for... that's an interesting challenge... 🤔 Maybe for a next article unless someone picks up the gauntlet!

(syntactical, monads not allowed)

You just read issue #14 of Curiosities by Tensegritics. You can also browse the full archives of this newsletter.

Share on Twitter Share on LinkedIn Share on Hacker News Share on Reddit Share on Mastodon
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.