A small JavaScript / TypeScript optimizing compiler written in Rust on top of
the oxc crate ecosystem (pinned to
v0.134.0). It parses JS/TS, optionally strips TypeScript types, runs a set of
sound optimization passes gated by a GCC-flavored -O level, and emits
optimized JavaScript plus a v3 source map.
Everything runs in-process: the produced binary never shells out to
node/terser/esbuild/swc or any other external program at runtime. All
work is done via linked Rust crates.
cargo build --release
# binary at ./target/release/oxidilToolchain: Rust 1.96.0 / cargo 1.96.0. The oxc crates are pinned to 0.134.0
(which requires Rust >= 1.94).
oxidil <INPUT> --out <FILE> [--out-map <FILE>] [--source-map <FILE>]
[-O<level>] [--ts-typeof]
[--enable <ID>]... [--disable <ID>]...
| Flag | Meaning |
|---|---|
<INPUT> |
Input file. SourceType is inferred from the extension: .js .jsx .mjs .cjs .ts .tsx. |
--out <FILE> |
Required. Where optimized JS is written. |
--out-map <FILE> |
Where the output source map is written. If omitted, no map file is written and no sourceMappingURL comment is appended (codegen still produces a valid map internally). |
--source-map <FILE> |
Path to an INPUT source map (the map for <INPUT>). When present, the final --out-map is composed so it points all the way back to the ORIGINAL authored sources. |
--ts-typeof |
Orthogonal flag enabling the ts-typeof-elimination pass (effective only at level >= 1; never at -O0). |
-O0 (also -0, --O0) |
No optimization (debug/baseline). |
-O1 (also -1, --O1, bare -O) |
Constant folding + peephole/algebraic. |
-O2 (also -2, --O2) |
Default. -O1 + dead-code elimination at full fixpoint. |
-O3 (also -3, --O3) |
-O2 with a higher fixpoint cap (more aggressive). |
-Os (also -s, --Os, -Oz) |
-O2 + identifier minify/rename + compact codegen (size). |
Optimization levels use GCC-canonical spelling (-O2, -Os, bare -O = -O1); the
short -2/-s and long --O2/--Os aliases are equivalent.
| --enable <ID> | Force-include a pass by id even if the level would gate it off. Repeatable. |
| --disable <ID> | Force-exclude a pass by id. Repeatable. Disable wins over enable. |
The driver selects which passes run, purely from the chosen -O level (passes
are gated centrally in the registry by a level/threshold, not by editing pass
code). --ts-typeof is orthogonal and layers on at any level >= 1.
| Level | Passes enabled | Fixpoint cap |
|---|---|---|
-O0 |
(none — parse → type-strip if TS → codegen passthrough) | 0 |
-O1 |
propagation, pure-eval, constant-folding, peephole-algebraic, control-flow-simplification | 1 (light) |
-O2 |
-O1 + cse-gvn, object-construction, array-construction, inlining, param-scalarization, dead-store-elimination, dead-param-elimination, dead-code-elimination |
8 (full fixpoint) |
-O3 |
-O2 passes + licm |
16 (more iterations) |
-Os |
-O2 passes + minify-rename + compact codegen |
8 |
--ts-typeof |
adds ts-typeof-elimination at any level >= 1 | — |
Per-pass level assignment encoded in the registry (gated centrally by
min_level() / os_only()):
| Pass id | Min level | Notes |
|---|---|---|
ts-typeof-elimination |
O1 + --ts-typeof flag |
orthogonal flag |
propagation |
O1 | const-prop + copy-prop |
pure-eval |
O1 | pure built-in / global folding |
constant-folding |
O1 | |
peephole-algebraic |
O1 | |
control-flow-simplification |
O1 | if/else, blocks, switch-on-const |
cse-gvn |
O2 | local value numbering |
object-construction |
O2 | fold property-store runs into object literals |
array-construction |
O2 | fold ascending indexed-store runs into array literals |
inlining |
O2 | single-use variable inlining |
param-scalarization |
O2 | options-object param → positional scalar params |
dead-store-elimination |
O2 | |
dead-param-elimination |
O2 | trailing unused params |
dead-code-elimination |
O2 | |
licm |
O3 | loop-invariant code motion |
minify-rename |
-Os only |
Run order is the table order above (typeof first to expose constants; propagate / fold / simplify; then CSE / inline; then dead-store / dead-param / DCE; LICM; rename last).
Pass ids (for --enable / --disable), in canonical run order. Every pass is
conservative: when it cannot prove a transform observationally equivalent
for all inputs it skips the transform. Each pass also bails entirely on programs
containing with or a direct eval(...) (scope/reference data is then
unreliable).
-
ts-typeof-elimination (
ts-typeof-elimination) — see the section below. -
propagation (
propagation, O1) — constant propagation + copy propagation for non-reassigned, non-closed-over locals. Module root scope is now in scope: a top-levelconst/letin an ES module is module-private (not a property of the global object, invisible to sibling scripts), so its reads may be propagated (even when exported — only the read sites change; the live export slot is untouched). In a script the top level is shared global-lexical / global-object state, so root-scope bindings are still refused. Soundness boundary: only propagates when the declaration dominates every use —varis never propagated (hoisted-undefinedreads), and forlet/constthe declarator must sit at the top level of its scope (not under any branch/loop) and every read must be textually after the initializer (so a Temporal-Dead-Zone read still throws). For copy-prop the alias's initializer must itself be throwless: every free identifier it reads must be provably already-initialized at the declarator (avar/function, or a lexical binding declared textually before it), so propagatingconst a = later; const later = 5;cannot move (and thereby delete) the alias's TDZReferenceError. Only finite-number / string / bool / null literals are synthesized; copy-prop re-checks shadowing per use site. -
pure-eval (
pure-eval, O1) — evaluates pure host built-ins (Math.*,String.fromCharCode,Number()/String()/Boolean(),"…".length, etc.) on constant arguments viaoxc_ecmascript, trusting a name to be the genuine global only when it resolves to no local symbol. Soundness boundary (GRANULAR monkey-patch guard): instead of bailing the whole pass on any global write, the pass computes the exact set of global names that are monkey-patched and suppresses folding only for those; every other built-in still folds (soparseInt("42")folds even whenMath.flooris patched). A name is poisoned when:- it is the target of an assignment / update (
Math = …,Math.floor = …, including inside destructuring patterns[Math.floor] = …andfor-of/for-inheads), or - it is the leaf of a write through a global-object alias
(
globalThis/window/self/global/frames):globalThis.Math = 1,globalThis["Math"] = 1, andglobalThis.Math.floor = fall poisonMath(not the alias root) — this is robust to constant-folding rewriting a computed key like"Ma"+"th"to"Math", or - it flows as the target object into a known mutator —
Object.defineProperty/defineProperties/assign/setPrototypeOf,Reflect.defineProperty/set/setPrototypeOf,x.__defineGetter__/__defineSetter__.
When the patched name cannot be determined statically — a computed non-literal key on a global-object alias (
globalThis[k] = v), or a mutator applied to the global object itself (Object.defineProperty(globalThis, …)) — the pass falls back to the conservative whole-pass bail (treat every global as patched). Aliasing a global into a local first (const m = Math; m.floor = f) is a known limitation that static analysis here does not catch. Never synthesizes NaN/Infinity/BigInt/undefined. - it is the target of an assignment / update (
-
constant-folding (
constant-folding, O1) — folds provably-constant, side-effect-free expressions to literals viaoxc_ecmascript'sConstantEvaluation. Consultsmay_have_side_effectsbefore replacing, so(f(), 5)is not folded to5(the call tof()is preserved). -
peephole-algebraic (
peephole-algebraic, O1) — sound algebraic / boolean simplifications:!(!!x) → !x, constant?:and&&/||/??short-circuit, and numeric identities (x*1,x/1,x-0) gated on a known-Number operand. Omitsx+0andx*0(-0/NaNhazards) and all bitwise rewrites. -
control-flow-simplification (
control-flow-simplification, O1) — else-after-return hoisting,if→ternary for matching simple arms, safe block flattening / empty removal, and switch-on-constant folding. Soundness boundary for switch folding: refuses if any DROPPED case (before the matched case, or any case when nothing matches and there is no default) hoists avar/function/class, and refuses if the kept region contains abreak/continue(checked recursively through blocks/if/try, but not into nested loops/switches that capture their own jumps) that would retarget once the switch is removed. Preserveslet/constTDZ and block scoping. -
cse-gvn (
cse-gvn, O2) — common-subexpression elimination via local value numbering: a repeated, pure, non-trivial expression over never-mutated locals is computed once into a freshconsttemp. Soundness boundary: coercion (ToPrimitive) is itself an observable side effect, andmay_have_side_effectsdoes not flag relational/equality/arithmetic operators over identifier operands, so the eligible operator set is restricted to ops that run no ToPrimitive hook and cannot throw on operand type: strict equality (===/!==), logical (&&/||/??), unary!/typeof/void, plus arithmetic / bitwise (+ - * / % ** & | ^ << >> >>>) when every operand is a provably-finite-Number(a never-mutatedconst/letwhose initializer is, transitively, a finite numeric literal or such an op over provably-number operands). Numbers are already primitive, so no coercion runs andnumber op numbernever throws — collapsing N evaluations to one changes neither value nor observable coercions. An operand that could be an object with avalueOf/toStringhook is excluded. Member/call/newexcluded. -
object-construction (
object-construction, O2) — folds a run of own-property stores into the object literal of the binding they build up:var x = {}; x.y = 1; x.z = 2;→var x = { y: 1, z: 2 };(the JS analogue of store-to-literal hoisting). Only a single, simple-identifiervar/let/constdeclarator whose initializer is an object literal is a head, and only a contiguous run of immediately-followingx.k = v/x["k"] = v/x[0] = vstores (plain=, non-optional member, statically-known key) is consumed. Soundness boundary: a store is a[[Set]](consults inherited accessors/non-writable data props) while a literal member is aCreateDataProperty(ignores the prototype) — they diverge only on a tainted prototype, so the pass bails the whole program on any mutation of%Object.prototype%(Object.prototype.k = …,Object.defineProperty/setPrototypeOf/Reflect.*on it,__defineGetter__/__defineSetter__), excludes the__proto__key (the prototype accessor / literal setter form), and refuses a base literal containing an accessor or__proto__:setter. The__proto__-via-local-alias and external-code taints are not detected (same posture as pure-eval). A store RHS may never reference the binding (x.y = xreads hoistedundefined/TDZ, not the live object). And because a folded literal that throws partway never assigns the binding (while the original keeps the partially-built object visible), an effectful/possibly-throwing RHS is folded only when a throw is unobservable — inside a function body with no enclosingtry, and the binding not closed over; otherwise every folded RHS must be a literal. Idempotent: the run is consumed, so a re-run finds no following store. -
array-construction (
array-construction, O2) — the array analogue of object-construction: a freshly-declared array built by an ascending, contiguous run of indexed stores folds into one array literal:var x = []; x[0] = 1; x[1] = 2;→var x = [1, 2];. Only a dense ascending run (each store writes the next index, starting at the dense base literal's length) is consumed — this preserves element evaluation order (a literal evaluates left-to-right) and density (a gap likex[0]=1; x[2]=3stops the run rather than synthesizing an elision). Soundness boundary: same[[Set]]vs CreateDataProperty hazard as object-construction, but an indexed store walksx → Array.prototype → Object.prototype, so the pass bails the whole program on any mutation of either%Array.prototype%or%Object.prototype%. The base must be a dense array literal (no spread / hole, so the next index is known), the store key a numeric literal that is a canonical array index (0 ≤ i < 2³²-1) equal to the next expected index, and the same throw-vs-binding rule applies (effectful/throwing RHS only when a throw is unobservable; else literal-only). A folded RHS may never reference the binding (var x = [x]reads hoistedundefined/TDZ). Overwrites ([a,b]; x[1]=c) and out-of-order stores are never folded (they would drop or reorder element evaluation). -
inlining (
inlining, O2) — single-use variable inlining: a single-assignment, single-use, non-closed-over local with a pure initializer has its initializer moved to the use. Module-root non-exportedconst/letare now inline-eligible (module-private; an exported root binding keeps its slot, and a script's root is never touched). Soundness boundary: the declaration must dominate the use (use textually after the init); if the declarator and use are in the same lexical scope the control-flow-nesting bail is relaxed (block statements run top-to-bottom and cannot be entered mid-way) — except inside aswitch, wherecaselabels jump forward into the shared block scope past an earlierconst's initializer, so a switch-nested declarator is never relaxed (the cross-case read is a TDZ access that must throw). The initializer must also be throwless: every free identifier it reads must be provably already-initialized at the declarator (so movingconst a = later; const later = 5;cannot delete the TDZ throw). If the initializer reads any free local, the use must be in the declarator's scope so no intervening block can re-resolve (shadow) that identifier. Never inlines a function (this/arguments/recursion hazards). -
param-scalarization (
param-scalarization, O2) — replaces a local function's trailing options-object parameter with the individual scalar parameters it decomposes into, rewriting every call site to pass the values positionally:function f(opts){return opts.a+opts.b}+f({a:1,b:g()})becomesfunction f(a,b){return a+b}+f(1,g())(interprocedural scalar replacement of a non-escaping aggregate; removes the per-call allocation and feeds the values to folding/inlining). Soundness boundary: the function must be a local, non-exported declaration (and, in a script, not a global root) whose every reference is a direct call callee (nonew f, alias, value use, tagged template); it must not usearguments; andopts(the last simple param) may appear only as a static, non-computed, non-optionalopts.<ident>— a read, or a=/compound/++write target (value mutation is fine: the literal is fresh and never escapes), never bare,opts[expr],delete opts.x, or spread. Every call site must pass an object literal with plain data properties only (a getter would run once-per-read vs once-as-value), no spread, static identifier keys, and no duplicates; a key the function never reads is dropped only if side-effect-free. A single canonical param order is chosen and each site's side-effecting values must keep their relative order under it. Missing keys becomevoid 0— sound only because used keys excludeObject.prototypenames and the pass bails on any program-level%Object.prototype%mutation (so an absent key readsundefined, not an inherited value). No used key may collide with another identifier in the function (the new param name could otherwise capture it). -
dead-store-elimination (
dead-store-elimination, O2) — removes assignments whose value is never read (and no-opx = x). Soundness boundary: never touches aconst/let/class(block-scoped) target — the write itself can throw (const TypeError, TDZ ReferenceError) — and never a function parameter (writing it is observable via a mappedargumentsobject in sloppy mode). Only simple=identifier targets; member stores (setters/proxies) excluded. -
dead-param-elimination (
dead-param-elimination, O2) — drops trailing unused parameters of a local function whose every use is a direct call, and the matching trailing args at each call site. Soundness boundary: a dropped argument must be side-effect-free and must not read a lexical (let/const/class) binding that could be in its TDZ at the call site (evaluating it throws — dropping it would delete the throw). Spreads, default params, rest params, destructuring, generators, andargumentsusers are excluded. -
dead-code-elimination (
dead-code-elimination, O2) — drops empty statements, truncates code after an unconditional terminator, collapsesif(<const>), and removes unused locallet/const/functionbindings viaoxc_semanticreference counts. Module-root non-exported bindings are now removable (module-private); a script's root scope and any exported module binding are still never removed. Soundness boundary: an unused declarator is removed only when its initializer is absent, or is side-effect-free and throwless w.r.t. identifier reads — every free identifier read must be provably already-initialized at the declarator (avar/function, or a lexical binding declared textually before it). This keepsconst unused = later; const later = 5;(a sibling TDZ read) andconst unused = notDefined;(an undeclared-global read) from having theirReferenceErrordeleted. -
licm (
licm, O3) — loop-invariant code motion: a pure, non-trivial invariant expression in a loop body is hoisted to aconsttemp before the loop. Soundness boundary: same non-coercing operator restriction as cse-gvn (including the provably-finite-Numberoperand broadening for the coercing arithmetic / bitwise ops), plus a TDZ-hoist guard — a block-scoped operand whose declaration is at/after the loop is rejected (reading it at the pre-loop site would throw when the loop runs zero times). Only never-mutated local / literal operands. -
minify-rename (
minify-rename,-Osonly) — short identifier names viaoxc_mangler's scope analysis. Bails the whole pass onwith/direct-eval.
--ts-typeof learns, for some local bindings, a statically-known runtime
typeof result, and folds typeof x === "T" (and !==/==/!=, plus
switch (typeof x)) into booleans so DCE can prune dead branches.
Soundness: TypeScript annotations are erased, not enforced at runtime, so
the pass never folds based on a parameter (or otherwise externally-supplied)
annotation — a value of a different runtime type routinely reaches an annotated
parameter via any, type assertions, or untyped JS callers. The only fact
source is a const binding to a simple identifier whose initializer's runtime
typeof can be pinned directly from the initializer expression (e.g.
const n = 42 → number, const s = "x" → string). Everything else poisons the
name: parameters, let/var, reassignments (including destructuring/member
assignment targets), x++, catch params, destructuring patterns, function/class
declaration names, and imports. Facts are keyed by name and a name is foldable
only if every binding/use agrees.
So function f(x: number) { if (typeof x === "string") ... } is left untouched,
while const n = 42; typeof n === "number" folds to true.
- With
--out-maponly: emits a valid v3 map from generated positions to the input file. - With
--out-mapand--source-map <input.map>: composes the codegen map with the input map so generated positions map back to the ORIGINAL authored sources. Token name ids are translated into the output map's ownnamestable (looked up by string and re-registered), so thenamesarray is correctly populated and every mapping's name index is in bounds — original identifier names (e.g. renamed-back parameter names) are preserved.
# Baseline (no opt), still emits a valid map:
oxidil sample.js --out out.js --out-map out.js.map -0
# Constant folding + peephole only:
oxidil sample.js --out out.js -1
# Default: O1 + DCE at full fixpoint:
oxidil sample.js --out out.js -2
# Aggressive:
oxidil sample.js --out out.js -3
# Size: minify + rename + compact output:
oxidil sample.js --out out.min.js -s
# TypeScript with typeof elimination + composed source map:
oxidil examples/sample.ts --out out.js --out-map out.js.map \
--source-map in.map --O2 --ts-typeof
# Override the level for one pass:
oxidil sample.js --out out.js -2 --disable dead-code-elimination
oxidil sample.js --out out.js -1 --enable dead-code-elimination# Unit + integration tests (the integration suite drives the real binary and,
# when `node` is on PATH, runs a differential check of -O1..-Os against -O0).
cargo test --release
# Differential corpus harness: compiles every program in difftest/corpus at
# each level (plus the --ts-typeof variants for .ts files), runs each through
# node, and reports any divergence from the -O0 baseline. Exits non-zero on a
# divergence. Needs `node` on PATH; build the release binary first (or point
# OXIDIL_BIN at another build).
cargo build --release
bash difftest/run.shCI (.github/workflows/test.yml) runs four jobs on every push — build & test,
the differential corpus harness, rustfmt --check, and clippy -D warnings —
on Rust 1.96.0 and Node 24, with the cargo registry and target/ cached across
runs.
Every pass favors correctness over completeness: it misses opportunities it cannot prove safe rather than risk a behavior change. Notable conservative gaps:
ts-typeof-eliminationonly acts onconstbindings with a directly pinnable literal/expression initializer. Parameter/exported/imported/letannotations are never folded.object-typed values are never pinned.- propagation only fires for
let/constwhose declarator is at the top level of its scope (not under a branch/loop) and precedes all uses in source order.varis never propagated, and a binding whose declarator is nested in any control-flow construct is skipped — so some genuinely-dominating uses are missed. Cross-fixpoint cascades are relied upon for chains. - pure-eval bails for the ENTIRE program if any global identifier or global
property is written anywhere — one
Math.foo = …disables all built-in folding program-wide (a deliberately blunt, sound over-approximation). - cse-gvn / licm only handle expressions built from strict-equality,
logical, and
!/typeof/voidoperators over never-mutated locals/literals; arithmetic, relational, and bitwise operators are excluded (their operand coercion is an observable side effect). LICM additionally rejects block-scoped operands declared at/after the loop (TDZ), which a precise reaching-definition analysis could sometimes admit. - inlining only inlines single-use variables (never functions), and requires the use to be in the declarator's own scope when the initializer reads a free local (a stricter rule than strictly necessary, but cheaply sound against block shadowing).
- dead-store-elimination never touches block-scoped (
let/const/class) or parameter targets, so onlyvar/assignable-local dead stores are removed. - dead-param-elimination keeps a trailing argument that reads any lexical binding (even one already initialized at the call site) to avoid deleting a possible TDZ throw — a conservative over-approximation.
- object-construction only folds a contiguous run of stores immediately
following a single-declarator object-literal binding (an intervening statement —
even an unrelated declaration — ends the run), only statically-known keys
(
x.k/x["k"]/x[0], neverx[expr]), and bails the whole program on any in-program mutation of%Object.prototype%(an aliasconst p = Object.prototype; p.k = …, or a taint from external code, is not detected). Inside atry, or for a closed-over / top-level binding, only literal-RHS stores fold (a possibly-throwing RHS could leave the binding unassigned where the original kept a partial object). Assignment-form heads (x = {}) are not handled. - array-construction only folds an ascending, contiguous run of indexed
stores starting at the dense base literal's length — a gap, an overwrite, or an
out-of-order store ends the run (folding them would drop or reorder element
evaluation, or need an elision the pass does not synthesize). It bails the whole
program on any in-program mutation of
%Array.prototype%or%Object.prototype%(alias / external taints undetected), and shares object-construction's throw-vs-binding and self-reference rules. Assignment-form heads (x = []) and string / computed-expression keys are not handled. - param-scalarization only splits a
functiondeclaration (never an arrow / function expression / method) whoseoptsis the last simple parameter, and only when EVERY call site passes an inline object literal — a variable, spread, or omitted argument at the opts position disables it (it does not inline a variable-held literal itself; theinliningpass may first). It bails on anyargumentsuse, any non-opts.<ident>use of the param, anObject.prototypename or in-program%Object.prototype%mutation, a key colliding with another identifier in the function, a getter/spread/duplicate-key/computed-key call-site literal, a side-effecting extra key, or call sites whose side-effecting values cannot share one evaluation order. Capped at 8 generated params, and an aliased / re-exported /new-constructed function is never touched. - control-flow-simplification's switch-on-constant fold keeps the switch
whenever the matched region contains a
break/continue(it does not yet rewrite a clean trailingbreakinto a fall-through-free block), so many break-terminated switches are left intact. - Module vs. script root scope (gated on the post-parse
SourceType): in an ES module (.mjs/.mts, or a.js/.tsthat actually usesimport/export) the top-levelconst/letare module-private — not properties of the global object and not visible to sibling scripts — so propagating, folding, inlining, and DCE-removing them is observationally invisible outside the module and is now enabled. An exported binding keeps its live slot (its reads may be propagated, but the binding itself is never removed/moved). In a script (.js/.cjswith no module syntax) the top level is shared global-lexical / global-object state observable by sibling scripts, so all root-scope optimization there stays refused. - Root-scope removal/inlining/propagation additionally requires the moved/deleted
initializer to be throwless w.r.t. identifier reads (no TDZ / undeclared-
global
ReferenceErrormay be deleted by relocating or dropping the read). - The same-scope inlining broadening does not apply across
switchcase labels (forward case-jumps bypass earlierconstinitializers). - Every symbol-touching pass bails entirely on
with/direct-evalprograms. - BigInt and
undefinedconstants are not synthesized by any folder.
The compile core is also published to npm as oxidil, built by compiling
this crate to wasm32-unknown-unknown (via wasm-pack) and wrapping it in a
typed TypeScript API + CLI. See npm/README.md. The pure,
file-I/O-free core lives in src/driver.rs (compile()) and is
shared by both the native binary and the WASM build; the bindings are in
src/wasm.rs (feature wasm).
Build everything (native binary + npm package, publish-ready) from the repo root:
make # = make rust + make npm
make rust # native release binary only
make npm # wasm + TypeScript package only
make publish # build + npm publishCargo.toml is the single source of truth for the version. make regenerates
the gitignored npm/package.json (from npm/package.tpl.json) and
npm/src/version.ts from it, so the crate, the npm package, and the CLI
--version always agree.
MIT © 2026 Astrid Gealer