How Irij ℑ Works
Under the hood of a token-efficient, effect-oriented language on the JVM — from source text to running bytecode. Reflects the codebase as of v0.8.x (post the 2026-07 refactor). Deep dives live in docs/internals/.
1The pipeline at a glance
Irij is bytecode-only: there is no interpreter (the tree-walker was removed in v0.6.20). Every run — a file, a REPL line, an nREPL eval, a packaged app — goes through the same four stages and executes as JVM classfiles.
irij run, REPL, nREPL
sessions, and irij build JARs.
Before emission, two whole-program passes run over the AST:
ModuleInliner splices used modules into
one declaration list, and EffectRowChecker verifies
declared effect rows (::: Console Db) at compile time.
Emission itself is multi-pass (signatures → methods → dispatchers →
main → <clinit>), so forward
references just work.
2Lexing: indentation & dense ASCII
Irij's surface syntax is built around two ideas:
blocks by indentation (strictly 2 spaces per level, hard
error otherwise) and 1–2 character operators chosen to be
single BPE tokens for LLMs (:= |>
@ /? /+ ~> …).
ANTLR can't produce Python-style INDENT/DEDENT tokens natively, so
IrijLexerBase measures leading spaces at each newline and
synthesizes them, maintaining an indent stack. Blank lines and
comments are consumed without emitting tokens (and, since a v0.8 fix,
without desyncing line numbers). Maximal-munch ordering matters:
::: (effect row) must lex before :: (spec
annotation), ..< before ...
Identifiers are kebab-case with optional ?/!
suffix (empty?, validate!);
_ works as a separator too (pw_hash), while
bare _ stays the wildcard pattern. Strings interpolate
with ${expr}.
3Parsing & the AST
The ANTLR4 grammar (IrijParser.g4) feeds
AstBuilder, which produces a small, regular AST of Java
sealed interfaces + records:
| Family | Variants (abridged) |
|---|---|
Decl |
FnDecl, SpecDecl, EffectDecl, HandlerDecl, CapDecl, ImplDecl, BindingDecl, PubDecl… |
Expr |
literals, Var, App, Lambda, IfExpr, MatchExpr, Block, Pipe, SeqOp, MapLit, StringInterp, Range, DotAccess… |
Stmt |
Bind, MutBind, Assign, ExprStmt, IfStmt, MatchStmt, With, Scope |
Pattern |
literal, Var, Wildcard, ConstructorPat, VectorPat (with spread), TuplePat, DestructurePat |
SpecExpr |
Name, App, ArrowSpec, RecordSpec, enum/refinement forms |
Sealed hierarchies mean every consumer is an exhaustive
switch over records — the compiler enforces coverage when
a new node appears. Function bodies come in three forms, disambiguated
by the first token after the indent: ( → lambda body,
pattern => → match arms, bare => →
imperative block. Lambda chains curry:
(a -> b -> body) ≡
(a -> (b -> body)).
4Bytecode emission
The emitter lives in dev.irij.compiler as an orchestrator
plus focused modules (one concern per class since the 2026-07 split):
| Class | Job |
|---|---|
ClassEmitter |
passes, per-source-file class management,
<clinit>, shared state (arities, effect rows,
spec/proto/handler registries)
|
FnEmitter |
named fns, tail returns, spec checks, pre/post contracts, fn-as-value wrappers |
ExprEmitter |
expressions & statements: literals, binds, calls, pipes, collections |
PatternEmitter |
match lowering: test-and-bind chains per pattern kind
|
LambdaEmitter |
lambda bodies as synthetic statics + free-variable capture analysis |
IntrinsicsEmitter |
builtin fast paths — direct INVOKESTATIC into the runtime |
EffectEmitter / SmClassifier /
SmEmitter
|
the effect system (§7) |
ProtoEmitter |
protocol dispatchers: type-tag switch →
impl$method$Type statics
|
Each program becomes one root class (irij.Program)
holding main, statics for top-level binds, and all
synthetic methods. Functions inlined from other source files move to
sibling classes (irij.Program$vrata_html) whose
JVM SourceFile
attribute names the real file — so stack traces read
at irij.Program$vrata_html.el(vrata/html.irj:40) instead
of blaming the root program. Per-expression
LineNumber entries make those traces line-accurate.
The runtime the emitted code calls into is split by domain:
RuntimeSupport (callable dispatch, nREPL namespace,
hot-redef bootstrap) plus RtOps, RtMath,
RtStrings, RtCollections,
RtEffects, RtConcurrency,
RtInterop, RtIo. Emit sites don't hard-code
an owner: RtOwners.of("conj") resolves the declaring
class reflectively at compile time and fails fast if a name is missing
or ambiguous — the runtime classes are the single source of truth.
5The value model
| Irij | JVM representation |
|---|---|
Int / Float / Bool /
Str
|
boxed Long / Double /
Boolean / String
|
() |
Values.UNIT singleton |
:keyword, 3/4 |
Values.Keyword, Values.Rational records
|
#[…] #(…) #{…}
{k= v}
|
IrijVector / IrijTuple /
IrijSet / IrijMap — immutable copies
(List.copyOf et al.)
|
| ADT / record values |
Values.Tagged(tag, fields, namedFields, specName) —
specName is the certification stamp
|
| functions |
RuntimeSupport.IrijFn SAM (Object[] → Object), built by LambdaMetafactory at call sites
|
Everything is Object in method signatures; HotSpot's
boxing elimination and inlining recover most of the cost at hot sites.
Maps are string-keyed (JSON-shaped); a dynamic key
{(expr)= v} must evaluate to Str.
6Tail-call optimization
Self-recursive calls in tail position compile to
GOTO back to the function entry with parameter slots
rebound in place — O(1) stack, no trampoline.
emitTailExpr walks tail positions (if-branches, last
block expression, match arm bodies, the last expression of a
do); everything else falls through to
emitExpr + ARETURN. New argument values are all evaluated
on the JVM stack before any slot is overwritten, so
sum-to (acc + n) (n - 1) can't read a half-updated frame.
Mutual recursion is deliberately deferred (no JVM-level general TCO).
7Effects: state-machine lowering
Algebraic effects are Irij's universal joint — IO, state, concurrency
and mocking all flow through effect declarations,
perform call sites, and with handler blocks.
The lowering (“14c.3”) turns each with body into a
resumable state machine; there are no threads or
queues in the dispatch path.
Classification
SmClassifier A-normalizes the body — every op call,
including one embedded inside a pipe, map literal, string
interpolation, range, record update, or do, is lifted in
source order to x := op args or a bare op statement —
then classifies it into a WithBodyShape:
| Shape | Meaning |
|---|---|
Pure |
no op calls — body runs directly |
SingleOp |
exactly one op call at top level |
Sequence |
a straight line of segments split at op calls; locals crossing a split are lifted into continuation fields |
EffIR |
branching control flow with ops — lowered to basic blocks with
Terms (Return / Jump / Branch / Perform)
|
Runtime protocol
The continuation object (IrijContinuation) holds
state (which segment runs next) and
fields (lifted locals). Handler mutable state (state :! 0, <-) is a named global cell: one
static field per handler declaration, initialized once at the
declaration and never reset by with — successive or
cross-thread with h blocks share it, and
h.state (a plain field read) stays valid after the
with returns, which is how test handlers hand results
back. Mutating one handler's state from parallel fibers is a knowing
data race. Handlers compose with
>> (CompiledComposedHandler); nested
with persists the inner continuation in the outer's
fields. A clause body that performs its own foreign effect
compiles as its own SM continuation (“tier-c”); ops the native
machinery can't route fall back to SM_STACK — a
thread-local stack of handler frames — so semantics never depend on
which path was taken. PerformSignal/TailResume
are created fresh per throw (stackless construction; pooling was
benchmarked at ~zero and removed).
8Hot redefinition
Dev builds compile each user-fn call site as
invokedynamic; the bootstrap
(RuntimeSupport.redefBootstrap) links a
MutableCallSite and registers it in a global map keyed by
owner.method:descriptor. Redefining a fn (REPL, nREPL)
swaps the call site's target method handle — every previously-JITted
caller re-links to the new code.
irij build --direct-linking emits plain
invokestatic instead: no indirection, maximal JIT
inlining, Clojure-style deploy semantics.
9Specs & contracts
Irij is spec-oriented rather than statically typed: annotations are runtime-validated shapes on function boundaries, with a gradual spectrum (dynamic → schemas → contracts).
pub fn greet :: Str Int Str ::: Console
pre (n -> n >= 0)
post (r -> length r > 0)
(name n -> ...)
-
Input specs validate each annotated param at entry
(
SpecValidator.validateEncoded, driven by an encoded spec string baked into the bytecode); output specs validate at every tail-return site. -
Product/sum specs register in a global descriptor
registry at
<clinit>. Validated values get certified —Tagged.specName— enabling an O(1) revalidation fast path. -
Union types: a sum spec may list primitive specs as
variants (
spec Node / Raw Str / El Str / Str); a bare primitive matches by type, not tag. -
Contracts (
pre/post, module-boundaryin/outwith blame) compile to IrijFn slots checked around the body. -
Effect rows (
::: Console Db, row variables,Any) are enforced at compile time byEffectRowChecker— call-site subsumption included — plus a thin runtime row stack for dynamic dispatch through fn values.
10Modules, seeds & versioning
use vrata.html is resolved by
ModuleInliner at compile time: the module's source is
parsed, its pub decls are unwrapped, short-name aliases
are rewritten at call and dot-access positions, and everything lands
in one decl list (per-file classes keep stack traces honest, §4).
Lookup order: stdlib classpath (std/*.irj) → project
source roots → installed seeds.
Seeds are Irij's packages:
irij.toml declares metadata + deps (registry / git);
irij install resolves them,
irij publish tars sources and uploads to the registry
(irij.online) with Bearer auth. Versions are
commit-count based —
MAJOR.MINOR.<git rev-list --count HEAD>; publish
runs only from a clean main so patch numbers are monotone and nobody
hand-picks them. The engine itself versions the same way.
11Concurrency
Everything is virtual threads.
spawn starts a fire-and-forget vthread;
scope/fork/await
give structured concurrency (CompiledScopeHandle), with
par, race, timeout on top. The
built-in HTTP server (IrijHttpServer) is
vthread-per-connection with blocking IO — a dead SSE peer kills only
its own thread (the com.sun.net.httpserver selector-wedge
bug that motivated it is gone).
Effect context crosses threads by snapshot: at fork
time the parent's handler stacks (SM_STACK, threaded
EffectSystem.STACK, effect row) are copied into a
ParentSnapshot and installed in the child before its body
runs; HTTP request handlers replay a snapshot taken at
serve time. The session-scoped values — nREPL namespace
NS and stdout capture SESSION_OUT — are JDK
25 ScopedValues: bound lexically around each
eval/fiber/request, structurally unable to leak. The mutable handler
stacks stay ThreadLocal on purpose: emitted code
pushes/pops them in generated-code scope, and children need copies,
not shared references.
12Capabilities & Java interop
Effects abstract what; capabilities bind how. Stdlib
effect modules (std.db, std.http,
std.serve, std.fs, std.session,
std.log…) pair a pub effect with a default
handler whose clauses call raw-* builtins backed by Java
capability classes (JdbcCapability,
ServeCapability, …). Tests swap the handler; production
code never changes.
Java interop is reflective and unified:
Class/member resolves statics,
obj.method args falls through dot-access to instance
reflection, both via JavaInterop's coercion/overload
resolution. Calling arbitrary Java is impure by definition, so it
demands the ::: JVM row — purity stays honest at the
boundary.
13The stdlib boundary
The standard library is
real Irij (src/main/resources/std/*.irj, 18
modules) except where Java is irreducible: raw access to internal
collection types, JDBC/HTTP/crypto, and hot arithmetic. Since the
2026-07 dedup there is
one implementation per builtin: the
Rt* statics serve call-position intrinsics directly, and
value-position BuiltinFn registry entries delegate to the
same statics. Higher-order builtins are effect-transparent — a
callback runs in the caller's effect row.
14Tooling: REPL to LSP
| Surface | How it works |
|---|---|
irij / irij repl |
JLine3 REPL; each form compiles as a namespace-mode class and
evals in a persistent BytecodeSession
|
irij --nrepl-server |
bencode nREPL; cross-eval state via
RT.nsGet/nsPut against the session's
ScopedValue-bound namespace; powers the Emacs client and the
irij.online Playground
|
irij lsp |
LSP4J over stdio: diagnostics from the real parser + effect-row checker, symbols from the AST index |
irij --mcp-server |
MCP over stdio so AI agents can eval/run/test a project directly |
irij build |
self-contained runnable JAR: emitted classes +
dev/irij/** runtime + stdlib +
resources/; --direct-linking for deploy
|
irij test |
discovers test-*.irj, counts std.test's
[ok]/[FAIL] lines, per-file + grand
totals
|
15Source map
| Area | Where |
|---|---|
| Grammar |
src/main/antlr/IrijLexer.g4,
IrijParser.g4, parser/IrijLexerBase.java
|
| AST |
ast/ — Expr, Stmt,
Decl, Pattern, SpecExpr,
AstBuilder
|
| Compiler |
compiler/ — IrijCompiler,
ClassEmitter + emitter modules,
SpecValidator, EffectRowChecker,
ModuleInliner
|
| Runtime |
compiler/RuntimeSupport + Rt*, runtime
types (IrijContinuation,
PerformSignal…), runtime/Values,
capabilities
|
| Tooling |
cli/, repl/, nrepl/,
lsp/, mcp/, module/ (seeds,
registry, versioning)
|
| Deep dives |
docs/internals/ — 18 topic pages,
kept in lockstep with the code
|
Generated for Irij v0.8.x · 2026-07 · This page is a curated overview; the internals pages are the source of truth for details.
