S-expression syntax for JavaScript
lykn is a lightweight Lisp that compiles to clean, readable JavaScript. No runtime, no dependencies in the output — just JS you'd write by hand, but expressed in s-expressions.
The name means good luck in Norwegian, luck in Swedish, and — if you squint at the Icelandic — closure.
v0.2.0 — Full macro system with quasiquote, auto-gensym hygiene, and cross-module macro imports. 56KB browser bundle. 423 tests.
;; Define macros with quasiquote templates
(macro when (test (rest body))
`(if ,test (block ,@body)))
(macro unless (test (rest body))
`(if (! ,test) (block ,@body)))
;; Import macros from other files
(import-macros "./control-flow.lykn" (when unless))
;; Use them like built-in forms
(when (> x 0)
(console:log "positive"))
;; Classes, destructuring, template literals — all the JS you need
(class Dog (Animal)
(field -name)
(constructor (name)
(super name)
(= this:-name name))
(speak ()
(console:log (template this:-name " says woof!"))))
(const (object name (default age 0)) (get-user))Compiles to:
if (x > 0) {
console.log("positive");
}
class Dog extends Animal {
#_name;
constructor(name) {
super(name);
this.#_name = name;
}
speak() {
console.log(`${this.#_name} says woof!`);
}
}
const {name, age = 0} = getUser();.lykn source → reader → expander → compiler → astring → JavaScript
-
Reader (
src/reader.js) — parses s-expressions, handles#dispatch (`,,,,@,#a(...),#o(...),#NNr,#;,#|...|#), dotted pairs -
Expander (
src/expander.js) — three-pass macro expansion pipeline. Resolves quasiquote (Bawden's algorithm), sugar forms (cons/list/car/cdr), user-defined macros,import-macros,aspatterns -
Compiler (
src/compiler.js) — transforms core forms to ESTree AST, generates JS via astring -
Browser shim (
src/lykn-browser.js) — 56KB bundle with<script type="text/lykn">support andwindow.lyknAPI -
Rust tools (
crates/lykn-cli/) — linter, formatter, syntax checker. Single binary, no runtime dependencies. Publishable to crates.io.
brew install biome deno# JS (src/)
deno lint src/
biome lint src/
# Rust
cargo clippy# JS (src/)
biome format src/
biome format --write src/ # fix in place
# Rust
cargo fmtdeno task test # all tests
deno task test:unit # unit tests only
deno task test:integration # integration tests only
cargo test # Rust tests<script src="dist/lykn-browser.js"></script>
<script type="text/lykn">
;; Macros work inline in the browser!
(macro when (test (rest body))
`(if ,test (block ,@body)))
(const el (document:query-selector "#output"))
(when el
(= el:text-content "Hello from lykn!"))
</script>Or use the API directly:
lykn.compile('(+ 1 2)') // → "1 + 2;\n"
lykn.run('(+ 1 2)') // → 3
await lykn.load('/app.lykn')Note:
import-macrosis not available in the browser (no file system access). Inlinemacrodefinitions work.
deno task build# Build from source
mkdir -p ./bin
cargo build --release && cp ./target/release/lykn ./bin
# Format a file (stdout)
./target/release/lykn fmt main.lykn
# Format in place
./target/release/lykn fmt -w main.lykn
# Syntax check
./target/release/lykn check main.lykn| lykn | JS |
|---|---|
(const x 1) |
const x = 1; |
(let x 1) |
let x = 1; |
my-function |
myFunction |
console:log |
console.log |
this:-name |
this.#_name |
(get arr 0) |
arr[0] |
| lykn | JS |
|---|---|
(=> (a b) (+ a b)) |
(a, b) => a + b |
(function add (a b) (return (+ a b))) |
function add(a, b) { return a + b; } |
(lambda (a) (return a)) |
function(a) { return a; } |
(async (=> () (await (fetch url)))) |
async () => await fetch(url) |
(=> ((default x 0)) x) |
(x = 0) => x |
(function f (a (rest args)) ...) |
function f(a, ...args) { ... } |
| lykn | JS |
|---|---|
(import "mod" (a b)) |
import {a, b} from "mod"; |
(import "mod" name) |
import name from "mod"; |
(export (const x 42)) |
export const x = 42; |
(export default my-fn) |
export default myFn; |
(dynamic-import "./mod.js") |
import("./mod.js") |
| lykn | JS |
|---|---|
(if cond a b) |
if (cond) a; else b; |
(? test a b) |
test ? a : b |
(for-of item items (f item)) |
for (const item of items) { f(item); } |
(while cond body...) |
while (cond) { body } |
(try body (catch e ...) (finally ...)) |
try { body } catch(e) { ... } finally { ... } |
(switch x ("a" (f) (break)) (default (g))) |
switch(x) { case "a": f(); break; default: g(); } |
(throw (new Error "oops")) |
throw new Error("oops"); |
| lykn | JS |
|---|---|
(template "hi " name "!") |
`hi ${name}!` |
(tag html (template ...)) |
html`...` |
(object (name "x") age) |
{name: "x", age} |
(array 1 2 (spread rest)) |
[1, 2, ...rest] |
(regex "^hello" "gi") |
/^hello/gi |
(new Thing a b) |
new Thing(a, b) |
| lykn | JS |
|---|---|
(const (object name age) person) |
const {name, age} = person; |
(const (array first (rest tail)) list) |
const [first, ...tail] = list; |
(const (object (alias data items)) obj) |
const {data: items} = obj; |
(const (object (default x 0)) point) |
const {x = 0} = point; |
(const (array _ _ third) arr) |
const [, , third] = arr; |
| lykn | JS |
|---|---|
(class Dog (Animal) ...) |
class Dog extends Animal { ... } |
(field -count 0) |
#_count = 0; |
(get area () (return x)) |
get area() { return x; } |
(static (field count 0)) |
static count = 0; |
(async (fetch-data () ...)) |
async fetchData() { ... } |
| lykn | JS |
|---|---|
(+ a b c) |
a + b + c |
(++ x) |
++x |
(+= x 1) |
x += 1 |
(** base exp) |
base ** exp |
(?? a b) |
a ?? b |
| lykn | What it does |
|---|---|
(macro when (test (rest body)) `(if ,test (block ,@body))) |
Define a macro with quasiquote template |
(import-macros "./lib.lykn" (when unless)) |
Import macros from another file |
`(if ,test ,@body) |
Quasiquote with unquote and splicing |
temp#gen |
Auto-gensym (hygienic binding) |
(gensym "prefix") |
Programmatic gensym |
| lykn | JS |
|---|---|
#a(1 2 3) |
[1, 2, 3] |
#o((name "x") (age 42)) |
{name: "x", age: 42} |
#16rff |
255 (radix literal) |
#2r11110000 |
240 (binary) |
(cons 1 2) |
[1, 2] |
(list 1 2 3) |
[1, [2, [3, null]]] |
(car x) / (cdr x) |
x[0] / x[1] |
#; expr |
Expression comment (discards next form) |
#| ... |# |
Nestable block comment |
- Thin skin over JS. lykn is not a new language. It's a syntax for the language you already have. The output should look like code you'd write.
- No runtime. Compiled lykn is just JS. Nothing extra ships to the browser.
- Small tools. The full pipeline (reader + expander + compiler) is ~3,000 lines. The browser bundle is 56KB minified. You can read the whole thing.
- Two worlds. Use Rust for dev-side tooling (fast, single binary). Use JS for the compiler (because it targets JS and can run in the browser).
- ESTree spec — the AST format lykn targets
- astring — ESTree to JS code generation
- Bawden 1999 — "Quasiquotation in Lisp", the algorithm behind lykn's macro expansion
- Fennel — inspiration for enforced gensym hygiene model
- eslisp — spiritual ancestor; reference implementation
- BiwaScheme — inspiration for the in-browser
<script>workflow
Apache-2.0
