Liking cljdoc? Tell your friends :D

clj-zig

CI Zig 0.16 Java 22+

clj-zig is an experiment, a proof of concept. It begins with a question: what would it look and feel like to bring systems-level programming into Clojure, and can that be done while keeping the data-oriented, REPL-driven workflow Clojure developers work in? Can a Clojure developer reach for Zig where native code is the right tool, in a form that stays idiomatic Clojure, without restricting what the native side can do?

This project explores how far those questions can be carried.

Stay in the REPL, define a function in a familiar shape, and drop into Zig only where native performance, explicit layout, comptime, or low-level control earns its keep.

The name is descriptive rather than clever: clj for Clojure, zig for the Zig that backs each function.

Core idea

(defnz add
  "Adds two signed integers."
  [x :i64
   y :i64
   :ret :i64]
  "return x + y;")

(add 20 22)
;; => 42

A normal Clojure function. The body is real Zig. The signature vector is a Clojure data contract describing the boundary.

A wider tour

The signature vector is the whole contract. Widen it one type at a time.

;; A slice arrives as a primitive array; :const makes it read-only.
(defnz sum
  [xs [:slice :const :f64]
   :ret :f64]
  "var t: f64 = 0; for (xs) |x| t += x; return t;")

(sum (double-array [1.0 2.0 3.0]))
;; => 6.0

Named boundary types cross by value. A defrecordz returns a Clojure record:

(defrecordz Point [x :f64 y :f64])

(defnz midpoint
  [a Point
   b Point
   :ret Point]
  "return .{ .x = (a.x + b.x) / 2.0, .y = (a.y + b.y) / 2.0 };")

(midpoint (->Point 0.0 0.0) (->Point 4.0 6.0))
;; => #user.Point{:x 2.0, :y 3.0}

A defenumz member bridges to a keyword. clj-zig copies an [:owned [:slice T]] return into a vector and frees the native memory; a [:handle T] is an opaque native resource the caller frees. Errors cross as data and allocations stay explicit. The Boundary Contract lists the full type vocabulary.

Bigger bodies and C interop

A body can also live in a real .zig file instead of a string. The file is ordinary Zig with full editor and zig fmt support; the generated wrapper calls its pub fn. The descriptor can link C libraries too, so a body may @cImport a C header directly:

(defnz hypotenuse
  [a :f64 b :f64 :ret :f64]
  {:zig/file "hyp.zig" :c/link ["m"]})
// hyp.zig
const c = @cImport({ @cInclude("math.h"); });
pub fn hypotenuse(a: f64, b: f64) f64 {
    return c.sqrt(a * a + b * b);
}

The file path resolves next to the source file, then on the classpath. See ADR 26 and ADR 27.

A namespace of native functions

A Clojure namespace is a Zig namespace. Name a function without a body and clj-zig takes the body from the pub fn of the same name in the .zig file beside the namespace's source: app/geometry.clj pairs with app/geometry.zig. Shared imports, helpers, and types live once in that file, and zig-deps declares the namespace's C link flags so each function inherits them:

(ns geometry
  (:require [clj-zig.core :refer [defnz zig-deps]]))

(zig-deps {:c/link ["m"]})         ;; link libm for the whole namespace

(defnz hypotenuse)                 ;; signature and body from geometry.zig's pub fn hypotenuse
(defnz circle-area)
//! clj-zig: geometry
const c = @cImport({ @cInclude("math.h"); });
fn square(x: f64) f64 { return x * x; }

pub fn hypotenuse(a: f64, b: f64) f64 { return c.sqrt(square(a) + square(b)); }
pub fn circle_area(r: f64) f64 { return 3.141592653589793 * square(r); }

With no signature, the boundary contract is inferred from the pub fn prototype: pub fn hypotenuse(a: f64, b: f64) f64 gives [a :f64 b :f64 :ret :f64]. A Zig type fixes the shape, but a returned []T or *T carries no ownership or handle policy in its type. A function returning one needs an explicit signature declaring [:owned ...] or [:handle ...]; until then it reports :clj-zig/contract-policy-needed.

Each function still compiles to its own content-addressed library, so redefining one recompiles only that one and a failed compile keeps the last good binding. A kebab-case name maps to its snake_case pub fn (circle-area to circle_area); the optional //! clj-zig: <ns> header asserts the file belongs to the namespace. A body file may @import sibling and subdirectory .zig files, which are reproduced and compiled alongside it. See ADR 28 and ADR 29.

Distributing a library

A library built with clj-zig ships its native code precompiled, so a consumer adds the dependency and calls functions with no Zig toolchain and no build step. At release, clj-zig.bake/bake! cross-compiles each defnz in a namespace for the target matrix into the resource tree the jar carries:

(clj-zig.bake/bake! {:ns 'com.example.widgets/native :out "resources"})

At load, a consumer's defnz resolves its baked library from the classpath by target, namespace, name, and content hash, extracts it into the cache, and binds it without invoking Zig. The hash uses the pinned Zig version, so the consumer reproduces the hash the author baked under without a toolchain; a platform the author did not bake is a clean miss, compiled locally when a toolchain is present. The default matrix is seven targets across Linux, macOS, and Windows; a function linking a third-party C library is baked for the host only. examples/build.clj shows bake, jar, and deploy. See ADR 31 and the Installation and Distribution guide.

Inspect and redefine

Every function is an ordinary Var carrying its spec, source, and build status:

(zig/spec #'sum)               ;; the normalized boundary contract, as data
(zig/generated-source #'sum)   ;; the full Zig wrapper
(zig/source #'sum)             ;; the body you wrote

Redefine like any defn, and a fresh library compiles. When a new body fails to compile, the diagnostic prints and the last good binding stays callable.

Pipeline

Clojure form
  ->  signature data
  ->  normalized boundary contract
  ->  generated Zig wrapper
  ->  Zig compilation
  ->  native library loading
  ->  ordinary Clojure Var

Examples

The examples/ directory holds small, runnable programs. Load one in a REPL and evaluate its (comment ...) block. The basics cover each boundary type one file at a time. Four go further, into work that is hard or impossible from the JVM:

  • simd.clj: explicit SIMD over @Vector registers.
  • memory_layout.clj: a packed native buffer mutated in place, no allocation, no GC.
  • bit_ops.clj: sub-byte packing and single-instruction bit intrinsics.
  • inline_asm.clj: inline assembly, with the bodies in sibling .zig files.

And two show a namespace backed by a co-located .zig:

  • cinterop.clj: imports a C header with @cImport and links a C library, its body in a sibling .zig file.
  • geometry.clj: bodyless functions sourced from the co-located geometry.zig, with zig-deps linking libm for the whole namespace.
  • multifile.clj: a body split across two .zig files, the first @importing the second.

Reading order

  1. Vision Brief: what clj-zig is, who it serves, what counts as success.
  2. Interface Design: defnz and the family of z-suffixed forms.
  3. Boundary Contract: how values cross and the type vocabulary.
  4. REPL and Execution Model: redefinition, caching, diagnostics.
  5. Composability and Builders: data-level reuse and macros.
  6. Proof-of-Concept Plan: scope, phases, acceptance tests.
  7. Design Principles and Decisions: the principles; decisions are ADRs in docs/adr/.
  8. Test Strategy: how generative and exhaustive testing prove the boundary, layered on the example suite.
  9. Installation and Distribution: the consumer and author flows, baking, and the toolchain bootstrap.

Requirements

  • Java 22 or newer. clj-zig uses the finalized Foreign Function & Memory API (JEP 454); --enable-preview is not required. The only flag the JVM needs is --enable-native-access=ALL-UNNAMED, which the :test alias sets.
  • Zig 0.16, for an author. clj-zig shells out to zig to compile generated source. It uses a zig on the path, or fetches a pinned one on first use (see below). A consumer of a library whose native code is already baked needs no Zig.
  • Clojure CLI (deps.edn, not Leiningen).

Development runs on JDK 26. If your shell's default JDK is older (for example through sdkman), point the Clojure CLI at JDK 26 for one invocation using JAVA_CMD:

JAVA_CMD="$(/usr/libexec/java_home -v 26)/bin/java" clojure -M:test

Installation

Add clj-zig to your deps.edn and open native access at runtime. Until a release is on Clojars, depend on it from git, pinning a commit:

{:deps {io.github.leifericf/clj-zig {:git/sha "<commit-sha>"}}
 :aliases
 {:dev {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}

Once published, depend on the released version from Clojars instead. The git coordinate (io.github.leifericf/clj-zig) and the Clojars coordinate (com.leifericf/clj-zig) differ; releases are dated:

{:deps {com.leifericf/clj-zig {:mvn/version "2026.06.17-alpha1"}}
 :aliases
 {:dev {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}

That one JVM flag is the only step native access requires; a running JVM cannot grant it to itself, so clj-zig cannot remove it. Without it, clj-zig reports :clj-zig/native-access-disabled naming the flag.

An author also needs a zig compiler. clj-zig uses one on the path, or fetches a pinned Zig into .clj-zig/zig/<version>/ on first use and reuses it, so installing Zig by hand is optional. On macOS with Homebrew:

brew install zig clojure/tools/clojure
brew install --cask temurin

A consumer of a library whose native code is baked needs no Zig at all. The Installation and Distribution guide covers the consumer flow, the author bake-and-publish flow, and the toolchain bootstrap.

Running the tests

clojure -M:test

Pure-core tests (signature, type, spec, source) run on any JDK 22+. The shell tests compile and load native code, so they need zig on the path and JDK 22+.

Non-goals for the proof of concept

  • No Zig-to-Clojure callbacks.
  • No embedded JVM from Zig.
  • No arbitrary Clojure object marshalling.
  • No hiding of Zig's type system.
  • No production packaging before the REPL experience is proven.
  • No DSL that pretends to be Zig but is not.

Acknowledgements

clj-zig grew out of several conversations with my friend @teodorlu in the Norwegian Clojure community.

License

Released under the MIT License.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close