Exploring Janet
Jul 2025 - Alex Alejandre

Although I’d seen Janet before, we finally got to know each other a few days ago and I’m heads over heels in love! Though 2x slower as a a CLI than Common Lisp, 1 MB vs. 60 MB executables and 1/3-1/4 the LoC are hard to argue with.

Closures are a bit different

# The expected form doesn't work:
(defn timer [t] (fn [] (set! t (+ t 1))))

# In Janet, params are constants (as if made by def). So you must shadow:
(defn timer [t] (var t t) (fn [] (set t (+ t 1))))

When you’re not sure what a function is named

I wanted to make nested maps from an array of strings. Perusing Janet’s formal and informal documentation, I failed to find something like Clojure’s assoc-in. I tried:

# time janet: .679s .649s .029s
# time jpm build: 15.779s 14.920s .533s
(map |(merge-into
       verses
       @{(get (string/split "\t" $) 1) (table)})
     v)
(map |(merge-into
       (verses (get (string/split "\t" $) 1))
       @{(get (string/split "\t" $) 2) (table)})
     v)
(map (fn (x) (put
              ((verses (get (string/split "\t" x) 1)) (get (string/split "\t" x) 2))
              (get (string/split "\t" x) 3)
              (get (string/split "\t" x) 4)))
     v)

N.b. janet executes the program as a script while jpm build compiles it. We are only comparing marshalling times, as the final data structure is the same.

I made an ugly macro expanding to this. I then thought to implement Clojure’s assoc-in more directly:

# time janet: .411s .38s .031s
# time jpm build: 14.392s 113.781s.469s
(defn assoc-in [m ks v]
``Insert (and make) deeply nested tables, based on Clojure's. Use like: (var t (table)) (assoc-in t [:a :b :c :d] "e")``
  (if (empty? ks)
    v
    (let [k (first ks)
          nested (or (get m k) @{})]
      (put m k (assoc-in nested (slice ks 1) v)))))

After this fun exploration, Caleb wrote Janet calls it put-in, with an unexpectedly imperative implementation here. A perfect drop-down replacement for my assoc-in, it exhibits the same (honestly irrelevant) performance characteristics.

# time janet: 0.383s 0.354s 0.29s
# time jpm build: 14.753s 15.064s .0591s
(var verses (table))
(var v (string/split "\n" (slurp "kjv.tsv")))
(defn insert-verse (abbrev ch verse quote) (put-in verses [abbrev ch verse] quote))
(map
 (fn (x)
   (let
    [line-as-array (string/split "\t" x)
     abbrev (get line-as-array 1)
     ch (get line-as-array 2)
     verse (get line-as-array 3)
     quote (get line-as-array 4)]

     (insert-verse abbrev ch verse quote)))
 v)

Are Immutable DS faster to access?

No, but I still wanted to investigate. I added these type casts, which did not noticeably impact marshal times:

(def verses (table/to-struct verses))
# ...
(var abbrev-array (tuple ;(keys abbrev-set)))

For actual execution, hyperfine "./test-janet gen 3:4" showed no appreciable difference:

Mutable: 48.1 ms ± 4.8 ms [User: 38.1 ms, System: 9.8 ms]

Immutable: 47.0 ms ± 3.5 ms [User: 37.4 ms, System: 9.5 ms]

For good measure, as a script, hyperfine "janet verse-reader.janet gen 3:4" gives:

Just slow: 246.3 ms ± 63.7 ms [User: 233.8 ms, System: 12.2 ms]

Image Based Development

Making an image is easy!

### in a repl
(def my-module @{:public true})
# (var voice [:unvoiced :voiced]) # do stuff here...
(spit "test.jimage" (make-image (curenv)))

janet -i test.jimage will run it. But how do you resume it?

(defn restore-image [image]
  (loop [[k v] :pairs image]
    (put (curenv) k v)))

(restore-image (load-image (slurp "test.jimage")))

Discovering this was somewhat painful but the community is very helpful!

This means there’s still work to create an image-driven development flow (and determine whether it’s better than sending source code to the REPL.)

Closures even stay synced across loads!

(defn timer [t] 
  (var t t)
   [(fn [] (set t (+ t 1)))
   (fn [] (set t (+ t 2)))])

(def tx (timer 0))
# call like this:
((tx 0))
((tx 1))

After saving the image as above, you can exit and start a new REPL, load the image and they will stay synced!

me:~$ janet
Janet 1.38.0-local linux/x64/gcc - '(doc)' for help
repl:1:> (defn timer [t] 
repl:2:(>   (var t t)
repl:3:(>    [(fn [] (set t (+ t 1)))
repl:4:([>    (fn [] (set t (+ t 2)))])
<function timer>
repl:5:> (def tx (timer 0))
(<function 0x5E0A7C67FBF0> <function 0x5E0A7C67FC50>)
repl:6:> ((tx 0))
1
repl:7:> ((tx 1))
3
repl:8:> (def my-module @{:public true})
@{:public true}
repl:9:> (var voice [:unvoiced :voiced])
(:unvoiced :voiced)
repl:10:> (spit "test.jimage" (make-image (curenv)))
nil
repl:11:> 
me:~$ janet
Janet 1.38.0-local linux/x64/gcc - '(doc)' for help
repl:1:> (defn restore-image [image]
repl:2:(>   (loop [[k v] :pairs image]
repl:3:((>     (put (curenv) k v)))
<function restore-image>
repl:4:> 
repl:5:> (restore-image (load-image (slurp "test.jimage")))
nil
repl:6:> ((tx 0))
4
repl:7:> ((tx 0))
5
repl:8:> ((tx 1))
7
repl:9:> ((tx 1))
9
repl:10:> 

How does it do this? I don’t know! Looking at the image:

@{_ @{:value @{:public true}} my-module @{:source-map ("repl" 10 1) :value @{:public true}} timer @{:doc "(timer t)\n\n" :source-map ("repl" 1 1) :value <function timer>} tx @{:source-map ("repl" 6 1) :value (<function 0x63B32169BF30> <function 0x63B32169C110>)}}

and current environment with (curenv):

@{_ @{:value <cycle 0>} my-module @{:source-map ("repl" 10 1) :value @{:public true}} restore-image @{:doc "(restore-image image)\n\n" :source-map ("repl" 1 1) :value <function restore-image>} timer @{:doc "(timer t)\n\n" :source-map ("repl" 1 1) :value <function timer>} tx @{:source-map ("repl" 6 1) :value (<function 0x63B321651220> <function 0x63B321651410>)}}

I thought the “function hashes” were only sometimes changing. But in IRC, Wolfdog explained:

<function 0x............> are just the address the object points to (see src/core/pp.c, function janet_to_string_b)

The first number after "repl" is the line it was defined on. I don’t know the 2nd yet.

Limits of Image Based Development

If you start a repl with janet -d, (dyn 'symb) will show you symb’s metadata, including its :source-form. While it works for locally defined forms, (get (dyn '+) :source-form) is nil. Potentially, one could build Janet while preserving such debug information.

C code can’t be marshalled into .jimage files, leading to projects like jayson.