Simplifying Javascript Class Extension in Clojurescript for ShadowCljs Projects

Disclaimer: Only meant for people starting ClojureScript

Recently, I’ve been working on a ShadowCljs project where I needed to extend a JavaScript class in Clojurescript, create an object, and pass it back to a JavaScript method. After researching various options, I found that the simplicity aspect was lacking. I decided to create a solution that addresses this issue.

Consider your npm package testcljs has a class

export class Vehicle{
constructor(public name: string, public speed: number){}

public getDetails(): string{
return `Vehicle: ${this.name} with speed ${this.speed}`;
}
}


export function drive(vehicle: Vehicle): string{
return `Driving ${vehicle.getDetails()}`;

}

Need to create a version of the Vehicle class that has a different implementation for the getDetails() method, while still being able to use the drive() function

Also new version of the method will call the super.getDetails() method to get the details from the parent class and add additional information before returning the final string

The typical Javascript which we need to implement in CLJS is something like this

export class ExtendedVehicle extends Vehicle{
constructor(public name: string, public speed: number){

super(name,speed);
}

public getDetails(): string{
return `Vehicle: ${super.getDetails()} and with extended capabilities`;
}
}

Lets see , How to create it , without much verbose and utilizing clojure’s simplicity

In Cljs import the class and method

(ns app.core
(:require
["test-cljs" :refer (Vehicle,drive) ] ))

Typically the drive method can be invoked with an instance of Vehicle

{:name "supercar"
:speed 200
:getDetails (fn [] "super-car with speed 200") }

convert the above map as a js object,#js makes a clojure map as a javascript object, it should always followed by clojure map

#js {
:name "supercar"
:speed 200
:getDetails (fn [] "super-car with speed 200") }

pass this js object as a parameter to drive method

(drive #js{
:name "supercar"
:speed 200
:getDetails (fn [] "super-car with speed 200")
})

;=> "Driving super-car with speed 200"

The above will work. But its so hard coded & also don’t have option to call super class.

we will address one by one. first we will create a function which will return an object for drive

(defn build-extended-vehicle [name speed]
#js{
:name name
:speed speed
:getDetails (fn [] (str name " with speed " speed))
}
)

The above function is an abstract layer for the hardcoded parameters. now invoke drive method

(drive (build-extended-vehicle "supercar" 200))
;=> "Driving supercar with speed 200"

The hardcoded element has been modified, but the question remains on how to access the methods of the parent class. In object-oriented programming, there is always an instance of a parent object available for every child object instance. We will proceed by refactoring the “build-extended-vehicle” method to utilize the parent object.

create a parent object

(let [parent-object (Vehicle. name speed)])

call the parent-object getDetails method and implement it

{
:getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" ))
}

The final code would be like this

(defn build-extended-vehicle [name speed]
(let [parent-object (Vehicle. name speed)]
#js{
:name name
:speed speed
:getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" ))
}
))

if we invoke drive method now

(drive (build-extended-vehicle "supercar" 200))
;=> "Driving Vehicle: supercar with speed 200 and with extended capabilities"

Some More Refactoring

if you look into the definition of build-extended-vehicle , if there is a new property added , we need to include that in vehicle object creation and also in js object. This can be fixed by a bit of refactoring

How to get all the properties and values for javascript Object.

Use JSON.stringify and parse

(->  (Vehicle. "supercar" 200) 
js/JSON.stringify
js/JSON.parse)

;=> #js{:name "supercar", :speed 200}

The response is a javascript object , convert as clojure map by with js-clj with use :keywordize-keys true so that object looks like clojure map

(defn jsobj->cljmap [input]
(->
input
js/JSON.stringify
js/JSON.parse
(js->clj :keywordize-keys true)))

( jsobj->cljmap (Vehicle. "supercar" 200))

;=> {:name "supercar", :speed 200}

Lets look into our build-extended-vehicle now

(defn build-extended-vehicle [name speed]
(let [parent-object (Vehicle. name speed)]
#js{
:name name
:speed speed
:getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" ))
}
))

rewrite the clojure map with assoc

(assoc 
(jsobj->cljmap parent-object)
:getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" ))))))

The #js. expects a map ,as we are merging maps , it will not work , we can use clj->js this is a function instead #js

(clj->js
(assoc (jsobj->cljmap parent-object)
:getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" ))))

The code started getting verbose , we will start thread-first macro

(->
parent-object
jsobj->cljmap
(assoc :getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" )))
clj->js )

The final code will look like the below

(defn build-extended-vehicle [name speed]
(let [parent-object (Vehicle. name speed)]
(->
parent-object
jsobj->cljmap
(assoc :getDetails (fn [] (str (.getDetails parent-object) " and with extended capabilities" )))
clj->js )))

(drive (build-extended-vehicle "supercar" 200))
;=> "Driving Vehicle: supercar with speed 200 and with extended capabilities"

The :getDetails inside threading macro seems a bit verbose, we can move that as variables and use merge to merge all method definitions

(defn build-extended-vehicle [name speed]
(let [
parent-object (Vehicle. name speed)
updated-methods {:getDetails #(str (.getDetails parent-object) " and s with extended capabilities" ) }]
(->
parent-object
jsobj->cljmap
(merge updated-methods)
clj->js )))

(drive (build-extended-vehicle "supercar" 200))
;=> "Driving Vehicle: supercar with speed 200 and with extended capabilities"

if you look into the entire method , there is two parts now , the parent object and updated-methods , we can again refactor to something like below

(defn to-extended-object [parent-object updated-methods]
(->
parent-object
jsobj->cljmap
(merge updated-methods)
clj->js ))

(defn build-extended-vehicle [name speed]
(let [
parent-object (Vehicle. name speed)
updated-methods {:getDetails #(str (.getDetails parent-object) " and s with extended capabilities" ) }]

(to-extended-object parent-object updated-methods)))

Okay in this way , we might require to implement all the functions, What if we pass the updated-methods as {} and it should just use the parent class method.

To do that , We need to first find out what are all the functions in object and get the corresponding functions and create that as a map.

How to get all functions in a javascript object and convert to map

  • get all the properties of javascript object through js-keys
(js-keys obj)
  • filter all the keys in object and check the corresponding key belongs to a function through

(aget obj key) - aget to retrieve the value corresponding to key

fn? - check the retrieved value is function

(filter #(fn? (aget obj %)) properties)

The final method will be like below, it will return all the function names

(defn get-all-function-names [obj]
(let [properties (js-keys obj)]
(filter #(fn? (aget obj %)) properties)
))

( get-all-function-names (Vehicle. "mycar" 125))
;=> ("getDetails")

Using the get-all-function-names method to retrieve all function names and reduce that to a map like below

(defn existing-functions-to-map [obj]
(let [ functions (get-all-function-names obj)]
(reduce (fn [acc fn-name]
(assoc acc (keyword fn-name) (aget obj fn-name)))
{}
functions)))

( existing-functions-to-map (Vehicle. "mycar" 125))
;=> {:getDetails #object[Function]}

The above code will return a map of all the existing functions

Update the existing to-extended-object method to merge functions returned from existing-functions-to-map

(defn to-extended-object [parent-object updated-methods]
(->
parent-object
jsobj->cljmap
(merge (existing-functions-to-map parent-object) updated-methods)
clj->js )
)

Lets look into our existing build-extended-vehicle method

(defn build-extended-vehicle [name speed]

(let [parent-object (Vehicle. name speed)
updated-methods {:getDetails #(str (.getDetails parent-object) " and with extended capabilities" ) }]

(to-extended-object parent-object updated-methods)))

(drive (build-extended-vehicle "mycar" 125))
;=> "Driving Vehicle: mycar with speed 125 and with extended capabilities"

if we change the updated-methods. to empty {}

(defn build-extended-vehicle [name speed]
(let [
parent-object (Vehicle. name speed)
updated-methods {}]
(to-extended-object parent-object updated-methods)))

(drive (build-extended-vehicle "mycar" 125))
;=> "Driving Vehicle: mycar with speed 125"

its using the parent method definitions for the overrides not been provided. You can override any methods to the updated-methods. variable.

So The build-extended-vehicle is the way to extend Vehicle class , while to-extended-object can be an abstraction to build extended class objects

Finally We can use plain clojurescript maps to be used as objects. instead of deftype with defprotocol. With a little bit of abstractions and refactoring, the programming is a breeze with Clojurescript while interacting with Javascript libraries

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store