How to create a library that works with Clojure and ClojureScript

Clojure and ClojureScript are forms of the same language targeting different hosts - JVM and JavaScript respectively. If you are creating a library, there is a big chance that a significant part of the code will work for both hosts but a part of it will be host-dependent.

Here we will discuss how to isolate the host-dependent parts of code to be used only when appropriate in order to write a single library that works for both Clojure and ClojureScript.

Steps that we are going to do:

  1. Create a simple logger Clojure library. Its (log) function will print the passed object with added timestamp and information from what language it was invoked.
  2. Create a Clojure app that uses the logger library
  3. Modify logger library to work with ClojureScript as well
  4. Create a ClojureScript app that uses the logger library to check that our modifications worked correctly

Creating Clojure version of logger library

Here is a folder structure for our logger library:

1
2
3
4
5
logger
├── deps.edn
└── src
    └── vkjr
        └── logger.clj

It’s deps.edn can be an empty map:

1
{}

And here is a code of the logger.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(ns vkjr.logger
  (:require [clojure.pprint :as pprint]))

(defn- timestamp []
  (.format (java.text.SimpleDateFormat. "d/M/yyyy, HH:mm:ss") (new java.util.Date)))

(defn log [arg]
  (pprint/cl-format true
                    "Time: ~S | Host: ~S | Object: ~S\n"
                    (timestamp)
                    "Clojure"
                    arg))

(timestamp) is a helper function that uses Java host features to get a formatted timestamp.

(log) is the function visible to the library users. It takes the user argument and using (cl-format) prints it prepended with timestamp and language name (”Clojure” in this case).

The first argument of (cl-format) - true, means that printing should be done to the default output. You can read more about this function in the official documentation.

Creating Clojure app to use logger library

Now let’s create a Clojure app to use the library. It will be called cljapp and put on the same lever with the logger:

1
2
3
playground
├── logger      <- logger library
└── cljapp      <- our new app

Here is a folder structure for cljapp:

1
2
3
4
cljapp
├── deps.edn
└── src
    └── core.clj

In deps.edn we’ll reference logger library by location on the filesystem:

1
{:deps {vkjr/logger {:local/root "../logger"}}}

And here is the code inside core.clj:

1
2
3
4
5
6
7
(ns core
  (:require [vkjr.logger :as logger]))

(defn -main [& _]
  (logger/log "Hi there!")
  (logger/log {:a 1 :b 2})
  (logger/log [1 2 3 4]))

We required the namespace of the logger library and used (logger/log) inside the (main) to print different arguments. Now let’s run the main function using Clojure CLI (from cljapp folder) to make sure it works correctly:

1
2
3
4
$ clj -M -m core                                          
Time: "18/8/2022, 16:39:30" | Host: "Clojure" | Object: "Hi there!"
Time: "18/8/2022, 16:39:30" | Host: "Clojure" | Object: {:a 1, :b 2}
Time: "18/8/2022, 16:39:30" | Host: "Clojure" | Object: [1 2 3 4]

Nice, as we see, it does)

Introducing reader conditionals

There is a part of Clojure tooling called Reader. It takes a textual code representation and turns it into the Clojure data structures. When you compile Clojure code, Reader will be responsible for processing your sources.

Reader supports two reader conditionals which allow you to specify different pieces of code and choose between them depending on the platform where the reader is invoked.

The standard reader starts with #? and looks like:

1
2
3
#?(:clj     (any Clojure expression)
   :cljs    (any ClojureScript expression)
   :default (default expression))

When Reader encounters such a conditional, it will leave only one expression in the result data structure - the one corresponding to the current host or the default one if the current host is not listed.

So after reading this code:

1
#?(:clj (+ 1 2) :cljs (+ 3 4))

On ClojureScript host Reader will return this datastructure:

1
(+ 3 4)

The splicing reader starts with #?@ and looks like this:

1
2
#?@(:clj  [vector of elements]
    :cljs [another vector of elements])

When it encountered, Reader will choose the vector depending on the host and will put the content of vector in the surrounding context. Not the vector itself! It’s content.

And after reading this code:

1
(print #?@(:clj [1 2] :cljs [3 4]))

on Clojure platform Reader will return the datastructure:

1
(print 1 2)

Note: in the source code reader conditionals work only in files with *.cljc file extension!

To grasp reader conditionals better you can experiment in REPL by feeding different code pieces to the read-string function (with {:read-cond :allow} as a first argument) and inspecting the output.

1
2
3
$ clj                               <- run repl
user=> (read-string {:read-cond :allow} "#?(:clj (+ 1 2) :cljs (+ 3 4))")
(+ 1 2)

Making the logger library work with ClojureScript

Now with all that knowledge about reader conditionals, it is time to revamp logger to make it work for ClojureScript.

First, we need to rename logger.cljlogger.cljc to enable reader conditionals.

Folder structure now:

1
2
3
4
5
logger
├── deps.edn
└── src
    └── vkjr
        └── logger.cljc

Next we need to add ClojureScript-related code in (comment) function in logger.cljc. It will be wrapped with standard reader conditional:

1
2
3
4
5
6
(defn- timestamp []
  #?(:clj
     (.format (java.text.SimpleDateFormat. "d/M/yyyy, HH:mm:ss") (new java.util.Date))
     :cljs
     (let [now (new js/Date)]
       (.toLocaleString now "en-US" #js{:hour12 false}))))

And as the last step, we modify (log) function to display the correct language name depending on the host. We use splicing reader conditional on doing this:

1
2
3
4
5
6
7
(defn log [arg]
  (pprint/cl-format true
                    "Time: ~S | Host: ~S | Object: ~S\n"
                    (timestamp)
                    #?@(:clj ["Clojure"]
                        :cljs ["ClojureScript"])
                    arg))

Full content of logger.cljc now:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
(ns vkjr.logger
  (:require [clojure.pprint :as pprint]))

(defn- timestamp []
  #?(:clj
     (.format (java.text.SimpleDateFormat. "d/M/yyyy, HH:mm:ss") (new java.util.Date))
     :cljs
     (let [now (new js/Date)]
       (.toLocaleString now "en-US" #js{:hour12 false}))))

(defn log [arg]
  (pprint/cl-format true
                    "Time: ~S | Host: ~S | Object: ~S\n"
                    (timestamp)
                    #?@(:clj  ["Clojure"]
                        :cljs ["ClojureScript"])
                    arg))

Now we need to check that changes didn’t affect the work of existing cljapp

Calling core namespace again from cljapp folder:

1
2
3
4
$ clj -M -m core                                          
Time: "18/8/2022, 16:50:39" | Host: "Clojure" | Object: "Hi there!"
Time: "18/8/2022, 16:50:39" | Host: "Clojure" | Object: {:a 1, :b 2}
Time: "18/8/2022, 16:50:39" | Host: "Clojure" | Object: [1 2 3 4]

Creating ClojureScript app to use logger library

And finally, we need to check that the library also works for the ClojureScript project. Let’s create one, called cljsapp on the same level as logger and cljapp:

1
2
3
4
playground
├── logger
├── cljsapp   <- ClojureScript app
└── cljapp      

Project structure:

1
2
3
4
cljsapp
├── deps.edn
└── src
    └── core.cljs

deps.edn content:

1
2
{:deps {org.clojure/clojurescript {:mvn/version "1.11.60"}
        vkjr/logger {:local/root "../logger"}}}

core.cljs content:

1
2
3
4
5
6
7
8
(ns core
  (:require [vkjr.logger :as logger]))

(defn -main [& _]
  (logger/log "Hi there!")
  (logger/log {:a 1 :b 2})
  (logger/log [1 2 3 4])
  (logger/log (new js/Date)))

And the actual check using Clojure CLI:

1
2
3
4
5
6
7
8
clj -M -m cljs.main -re node -m core
Time: "8/19/2022, 13:45:03" | Lang: "ClojureScript" | Object: "Hi there!"

Time: "8/19/2022, 13:45:03" | Lang: "ClojureScript" | Object: {:a 1, :b 2}

Time: "8/19/2022, 13:45:03" | Lang: "ClojureScript" | Object: [1 2 3 4]

Time: "8/19/2022, 13:45:03" | Lang: "ClojureScript" | Object: #inst "2022-08-19T12:45:03.775-00:00"

Perfect, now we have a library that works for both Clojure and ClojureScript :)

Complete code for on github

Official documentation on reader conditionals

Clojure ClojureScript library reader conditional