DEV Community

Volodymyr Kozieiev
Volodymyr Kozieiev

Posted on • Originally published at kozieiev.com

How to create a library that works with Clojure and ClojureScript


this article originally posted at https://kozieiev.com

Content:

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:

logger
├── deps.edn
└── src
    └── vkjr
        └── logger.clj
Enter fullscreen mode Exit fullscreen mode

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

{}
Enter fullscreen mode Exit fullscreen mode

And here is a code of the logger.clj:

(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))
Enter fullscreen mode Exit fullscreen mode

(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:

playground
├── logger      <- logger library
└── cljapp      <- our new app
Enter fullscreen mode Exit fullscreen mode

Here is a folder structure for cljapp:

cljapp
├── deps.edn
└── src
    └── core.clj
Enter fullscreen mode Exit fullscreen mode

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

{:deps {vkjr/logger {:local/root "../logger"}}}
Enter fullscreen mode Exit fullscreen mode

And here is the code inside core.clj:

(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]))
Enter fullscreen mode Exit fullscreen mode

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:

$ 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]
Enter fullscreen mode Exit fullscreen mode

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:

#?(:clj     (any Clojure expression)
   :cljs    (any ClojureScript expression)
   :default (default expression))
Enter fullscreen mode Exit fullscreen mode

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:

#?(:clj (+ 1 2) :cljs (+ 3 4))
Enter fullscreen mode Exit fullscreen mode

On ClojureScript host Reader will return this datastructure:

(+ 3 4)
Enter fullscreen mode Exit fullscreen mode

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

#?@(:clj  [vector of elements]
    :cljs [another vector of elements])
Enter fullscreen mode Exit fullscreen mode

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:

(print #?@(:clj [1 2] :cljs [3 4]))
Enter fullscreen mode Exit fullscreen mode

on Clojure platform Reader will return the datastructure:

(print 1 2)
Enter fullscreen mode Exit fullscreen mode

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.

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

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:

logger
├── deps.edn
└── src
    └── vkjr
        └── logger.cljc
Enter fullscreen mode Exit fullscreen mode

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

(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}))))
Enter fullscreen mode Exit fullscreen mode

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:

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

Full content of logger.cljc now:

(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))
Enter fullscreen mode Exit fullscreen mode

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

Calling core namespace again from cljapp folder:

$ 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]
Enter fullscreen mode Exit fullscreen mode

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:

playground
├── logger
├── cljsapp   <- ClojureScript app
└── cljapp      
Enter fullscreen mode Exit fullscreen mode

Project structure:

cljsapp
├── deps.edn
└── src
    └── core.cljs
Enter fullscreen mode Exit fullscreen mode

deps.edn content:

{:deps {org.clojure/clojurescript {:mvn/version "1.11.60"}
        vkjr/logger {:local/root "../logger"}}}
Enter fullscreen mode Exit fullscreen mode

core.cljs content:

(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)))
Enter fullscreen mode Exit fullscreen mode

And the actual check using Clojure CLI:

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"
Enter fullscreen mode Exit fullscreen mode

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

Links

Complete code for on github

Official documentation on reader conditionals

Top comments (0)