Clojure File System Essentials for Node.js Developers

Clojure File System Essentials for Node.js Developers

This will be a straightforward post with Javascript to Clojure (on JVM) examples of filesystem operations: listing, reading, writing, renaming, and deleting files. Let me know if you're interested in how to use Node packages from Clojurescript.

So, let's get to it.

In Javascript, we'd import the fs-module.

const fs = require('node:fs/promises')

I'll be running all of the Javascript blocks inside a (async () => { ...code... })() closure.

In Clojure, we'd reach out for

(require '[ :as io])

Node Filehandle and Java File

In Javascript, we can get a reference to a file with a Filehandle.

let directory = await".");


// FileHandle {
//     close: [Function: close],
//     [Symbol(kHandle)]: FileHandle {},
//     [Symbol(kFd)]: 20,
//     [Symbol(kRefs)]: 1,
//     [Symbol(kClosePromise)]: null
// }

FileHandle can be either a directory or a file.

let directoryStats = await fs.stat(".");
directoryStats.isFile(); // false
directoryStats.isDirectory(); // true

let fileStats = await fs.stat("files/a.txt");
fileStats.isFile()); // true
fileStats.isDirectory(); // false

The basic building block in Clojure is

User interfaces and operating systems use system-dependent pathname strings to name files and directories. This class presents an abstract, system-independent view of hierarchical pathnames.


(io/file ".")
;; => #object[ 0x54162701 "."]

Similarly to Filehandle in JS, can be either a directory or a file. The File has isDirectory and isFile methods that can be called via Java-interop.

(.isDirectory (io/file "."))
;; => true

(.isFile (io/file "."))
;; => false

(.isDirectory (io/file "deps.edn"))
;; => false

(.isFile (io/file "deps.edn"))
;; => true

Let's see what other properties the has with the bean function.


Takes a Java object and returns a read-only implementation of the map abstraction based upon its JavaBean properties.


(bean (io/file "."))
;; => 
{:path ".",
 :freeSpace 146245873664,
 :parent nil,
 :directory true,
 :parentFile nil,
 :name ".",
 :file false,
 #object[ 0x4f2abe7e "/home/tvaisanen/projects/tmp"],
 :absolute false,
 #object[ 0x5594224b "/home/tvaisanen/projects/tmp/."],
 :hidden true,
 :canonicalPath "/home/tvaisanen/projects/tmp",
 :usableSpace 124331769856,
 :totalSpace 429923737600,
 :absolutePath "/home/tvaisanen/projects/tmp/."}

Listing Files

Given we have the following file structure.

❯ tree files
├── a.txt
├── b.txt
├── c.txt
└── nest
    └── a.txt

In Javascript, we'd typically write.

let dir = await fs.readdir("files");
// [ 'a.txt', 'b.txt', 'c.txt', 'nest' ]

And in Clojure, we'll take the folder we want to list as and list all the files with file-seq.

(file-seq (io/file "files"))
;; =>
(#object[ 0x7e2b40d7 "files"]
 #object[ 0x22aaad8f "files/a.txt"]
 #object[ 0x3730fe92 "files/b.txt"]
 #object[ 0x62aaf1b "files/c.txt"]
 #object[ 0x22236af1 "files/nest"]
 #object[ 0x55126def "files/nest/a.txt"])

I'd expect this to return files similarly fs.readdir, but now we have the nested files in the listing. Let's see why that happens and if we could get it to work similarly to the listing in Javascript.

If we take a look at the file-seq source code

(defn file-seq
  "A tree seq on"
  {:added "1.0"
   :static true}
     (fn [^ f] (. f (isDirectory)))
     (fn [^ d] (seq (. d (listFiles))))

The docstring says that the function returns a tree seq, which sounds like a non-flat structure. And if we look deeper into tree-seq function and get the docstring.

Returns a lazy sequence of the nodes in a tree, via a depth-first walk. ...

And here's the explanation of what is happening: "a depth-first walk." This function will go first till the end of the file tree and start coming back towards the root directory. The first argument is a function that returns a boolean value to decide whether the node (read file or directory) needs to be traversed (read looked into). If it returns true, the same file is passed to the second function to list its nodes (read files and subdirectories). Based on this, the tree-seq will do a depth-first search and, therefore, read all the subdirectories.

You might have already noticed that the second function argument calls (. d (listFiles)) to get the files for our directory d. Let's use this to mimic the JS version readdir.

(seq (. (io/file "files") listFiles))
;; =>
(#object[ 0x356bf4d3 "files/a.txt"]
 #object[ 0x13c28931 "files/b.txt"]
 #object[ 0x444349ab "files/c.txt"]
 #object[ 0x2e64f09c "files/nest"])

At this point, the expected result is almost identical to the Javascript's fs.readdir. We have the files listed in the directory, but instead of file names, we have instances. Let's iterate over the files and once again use Java-interop with the getName method.

(for [file (seq (. (io/file "files") listFiles))]
  (.getName file))
;; => ("a.txt" "b.txt" "c.txt" "nest")

The fs.readdir function will throw if called with a filename that is not a directory. If we try to create a file sequence from a that is not a directory, we get only the file itself in a list.

(file-seq (io/file "files/a.txt"))
;; => (#object[ 0x37eeb5e7 "files/a.txt"])

Let's combine what we've learned from the previous examples into a new function read-dir that takes a path, checks that it is not a file, and then lists the files and directories in the folder but not the subpages.

(defn read-dir [d]
  (let [directory (io/file d)]
    (when (. directory isFile)
      (throw (ex-info "Path is not a directory" {:path directory})))
     (for [file (seq (. directory listFiles))]
       (.getName file)))))

(read-dir "files")
;; => ("a.txt" "b.txt" "c.txt" "nest")

(read-dir "files/a.txt")
;; Unhandled clojure.lang.ExceptionInfo
;; Path is not a directory
;; {:path #object[ 0x7c7a2da9 "files/a.txt"]}

That's enough on listing files for now. Next, let's look into reading the files.

Read Files

Let's see the content for a couple of the example files.

❯ cat files/a.txt
❯ cat files/b.txt

In Javascript, we'd typically use the fs.readFile.

let content = await fs.readFile("files/a.txt", {encoding: "utf8"});

In Clojure, we default to slurp.

(slurp (io/file "files/a.txt"))
;; => "AAAAA\n"

Slurp also works with the filename since if the argument is a string; it's first coerced (read interpreted as) as a URI and second as a local file.

(slurp "files/a.txt")
;; => "AAAAA\n"

Both fs.readdir and slurp read the file into memory at once; therefore, you might want to avoid it in production!

The more responsible way is to read the files as streams line by line.

let file = await'files/b.txt',);

for await (const line of file.readLines()) {
// 1111
// 2222
// 3333

In Clojure, we can use the to create a buffered reader for streaming over the files. slurp uses the same mechanism, but instead of reading a file line by line, it reads it all simultaneously.

(io/reader  "files/a.txt")
;; => #object[ 0x4754cc18 ""]

We can open this stream (read file) to create a lazy line sequence that can be processed one line at a time.

(with-open [reader (io/reader  "files/b.txt")]
  (doall (for [line (line-seq reader)]
           (str "read: " line))))
;; => ("read: BBBB" "read: 1111" "read: 2222" "read: 3333")

Next, to writing files.

Writing Files

With Javascript, we'd typically do this with fs.writeFile or use fs.createFileStream for extra control.

await fs.writeFile("files/write-with-js.txt", "writing from JS");

let content_01 = await fs.readFile("files/write-with-js.txt", 
                                   {encoding: "utf8"});
// writing from JS

In Clojure, similarly to slurp we have spit to write data.

(spit "files/new.txt" "New content here")
;; => nil
(slurp "files/new.txt")
;; => "New content here"

By default, spit overrides the file with the given content. To add to the file, use arguments :append true.

(spit "files/new.txt" "Append to file")
;; => nil
(slurp "files/new.txt")
;; => "Append to file"
(spit "files/new.txt" " Try again" :append true)
;; => nil
(slurp "files/new.txt")
;; => "Append to file Try again"

spit wraps the, which can be used directly. Similarly to slurp replaces the file content if :append true is not passed as an option.

(with-open [writer (io/writer "files/other.txt")]
  (.write writer "text here"))

(slurp "files/other.txt")
;; => "text here"

(with-open [writer (io/writer "files/other.txt" :append true)]
  (.write writer " more text"))
;; => nil

(slurp "files/other.txt")
;; => "text here more text"

Let's go through a couple of more use cases.

Renaming Files

In Javascript, we rename files with fs.rename.

await fs.rename("files/c.txt", "files/d.txt");

In Clojure, we use the renameTo method.

(.renameTo (io/file "files/new.txt")
           (io/file "files/newer.txt"))
;; => true
(.isFile (io/file "files/new.txt"))
;; => false
(.isFile (io/file "files/newer.txt"))
;; => true

Deleting Files

In Javascript, we delete files with fs.unlink

 await fs.unlink("files/d.txt");

In Clojure, we have for the same task.

(.isFile (io/file "files/other.txt"))
;; => true
(io/delete-file "files/other.txt")
;; => true
(.isFile (io/file "files/other.txt"))
;; => false


Clojure can do the same tasks as Javascript on the filesystem level since provides utility functions as a layer on top of classes. If there's no function matching your need, you can use Java-interop by, for example, using the methods. If you hit a roadblock, check the following resources for more hints.

Once again, I hope you found this helpful. Feel free to reach out and let me know what pain points you might have encountered when learning Clojure and what type of posts would help make the jump—social links in the menu.

Thank you for reading.