Curiosities by Tensegritics

Archives
March 27, 2026, 2:09 p.m.

When You Run Out of Types...

Curiosities by Tensegritics

by cgrand (🦋 🦣 𝕏)

There is a modeling technique I’ve used in the past, and the more I use it, the more it feels like the right default in a certain class of problems.

The situation is simple enough: you model with data, happily, until one day plain values are no longer enough.

Not because you need more structure.

Because you need more distinctions, more equivalence classes.

You have values representable by the same collection type but they should not be confused. At this point we usually reach for one of three things:

  • maps with a :type-like key (or worse: keyset-sniffing!),
  • metadata,
  • defrecord or deftype.

They all work... to some extent.

They all fail in the same way: code that looks sensible do the wrong thing, because the nuances, the invariants of our fifty shades of maps gets ignored.

Let's review them!

Maps with a :type

The classic just add a :type key, one can't go wrong with classics, right? Right?

{:type ::user-id
 :value 42}

Good enough for a while but the cost is that you are still working with a map.

Sooner or later, someone writes or runs map code over it as if it were a plain map.

It's not that one shouldn't be able to use generic functions on them, just that one shouldn't be able to use generic functions on them without being reminded they are no plain maps.

Metadata

Metadata is attractive because it does not pollute the value itself. Unfortunately that is also why it is such a poor fit for modeling: metadata is not part of equality.

Plus it's not printed by default, preserving metadata across transformations is a constant cognitive overload.

defrecord and deftype

Okay, deftype can do the job, at the cost of a lot of boilerplate to give it value semantics.

Wait! Isn't defrecord essentially deftype with value semantics? Yes, it ticks all the boxes: value semantics with its own equivalence class and prints clearly. The catch is that map? returns true on records.

Is that really a problem? Yes because one can't guard every map? with a record? (especially when using third-party code).

Imagine the mess if (every? fn? [:a 'a [] {} #{} #'*out*]) was true. That's why we have fn? and ifn?.

user=> (every? fn? [:a 'a [] {} #{} #'*out*])
false
user=> (every? ifn? [:a 'a [] {} #{} #'*out*])
true

Sadly we have no imap?.

Plus you have to go through protocols or instance? checks to tell them apart. Nothing as easy (or simple? 🤔) than :type. (Yes, there's type but then you can't have types in a case...)

Last you have the hysteresis issues caused by records silently downgrading to maps when a field key is dissoc-ed.

The silver bullet: Tagged Values

All hope is not lost, I've been increasingly trodding a fourth path: tagged values.

The idea is to (ab)use the tagged-literal function to create values which can't be construed for others.

user=> (tagged-literal `customer {:name "Wile E. Coyote"})
; prints clearly by default
#user/customer {:name "Wile E. Coyote"}

user=> (= (tagged-literal `supplier {:name "Wile E. Coyote"}) (tagged-literal `customer {:name "Wile E. Coyote"}))
; each tag is in its own equivalence class
false

user=> (= {:name "Wile E. Coyote"} (tagged-literal `customer {:name "Wile E. Coyote"}))
; since they have their own equivalence class, they are not equal to maps
false

user=> (map? (tagged-literal `customer {:name "Wile E. Coyote"}))
; they are no maps
false

user=> (coll? (tagged-literal `customer {:name "Wile E. Coyote"}))
; not even collections
false

user=> (:tag (tagged-literal `customer {:name "Wile E. 
Coyote"}))
; still, accessing the tag is easy
user/customer

user=> (:form (tagged-literal `customer {:name "Wile E. Coyote"}))
; as well as accessing the payload.
{:name "Wile E. Coyote"}

It is a wrapper with meaning, with no ceremony.

The important part is not the printed literal syntax. In fact the reader is beside the point here. The important part is that you can create a distinct semantic value for free!

So tagged value buys you something very simple and valuable: safe modeling space! (Fresh equivalence classes.)

If a plain 42 and a "user id 42" should not be interchangeable, then they should not be equal, not be confused, and not accidentally flow through the same code paths. This is what tagged values give you: not more structure, but stronger distinction to prevent unknowingly sending specific data through generic paths and its counterpoint avoiding to make specific pipelines accidentally generic.

Closing

Clojure makes it blissfully easy to model with plain data. That is one of its strengths.

When you run out of types, you don't need more shapes, you need more separation and that's what tagged values brings to the table at almost no cost.

Once you start seeing some modeling problems in terms of equivalence classes rather than representation, they make more and more sense.

Give them a try and let the community know!

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

Share this email:
Share on Twitter Share on LinkedIn Share on Hacker News Share on Reddit Share on Mastodon
Powered by Buttondown, the easiest way to start and grow your newsletter.