OXC
Elixir bindings for the OXC JavaScript toolchain via Rust NIFs.
Parse, transform, minify, lint, and generate JavaScript/TypeScript at native speed.
Features
- Parse JS/TS/JSX/TSX into ESTree AST (maps with atom keys, snake_case types)
- Codegen — serialize AST maps back to JavaScript source via OXC's code generator
- Bind — substitute
$placeholdersin parsed AST (quasiquoting for JS) - Transform TypeScript → JavaScript, JSX →
createElement/jsxcalls - Minify with dead code elimination, constant folding, and variable mangling
- Lint with 650+ built-in oxlint rules + custom Elixir rules
- Bundle multiple TS/JS modules into a single IIFE with dependency resolution
- Rewrite specifiers — rewrite import/export paths in a single pass
- Collect imports — typed import analysis (static/dynamic, import/export/export_all)
- Walk/Collect helpers for AST traversal and node filtering
- Postwalk with accumulator for AST-based source patching (like
Macro.postwalk/3) - Patch string — apply byte-offset patches to source (like
Sourceror.patch_string/2) - Import extraction — fast NIF-level import specifier extraction
Installation
def deps do
[
{:oxc, "~> 0.8.0"}
]
end
Precompiled NIFs are available for macOS (aarch64, x86_64) and Linux (aarch64, x86_64, musl).
Building from source requires a Rust toolchain (rustup recommended).
Usage
Parse
{:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
ast.type
# :program
[stmt] = ast.body
stmt.expression
# %{type: :binary_expression, operator: "+", left: %{value: 1}, right: %{value: 2}}
File extension determines the dialect — .js, .jsx, .ts, .tsx:
{:ok, ast} = OXC.parse("const x: number = 42", "test.ts")
{:ok, ast} = OXC.parse("<App />", "component.tsx")
AST node :type and :kind values are snake_case atoms (e.g. :import_declaration, :variable_declaration, :const).
Codegen
Generate JavaScript source from an AST map — the inverse of parse/2.
Uses OXC's code generator for correct operator precedence, formatting, and semicolons:
{:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
{:ok, js} = OXC.codegen(ast)
# "const x = 1 + 2;\n"Construct AST by hand and generate JS:
ast = %{type: :program, body: [
%{type: :function_declaration,
id: %{type: :identifier, name: "add"},
params: [%{type: :identifier, name: "a"}, %{type: :identifier, name: "b"}],
body: %{type: :block_statement, body: [
%{type: :return_statement, argument: %{type: :binary_expression, operator: "+",
left: %{type: :identifier, name: "a"}, right: %{type: :identifier, name: "b"}}}
]}}
]}
OXC.codegen!(ast)
# "function add(a, b) {\n\treturn a + b;\n}\n"Bind (Quasiquoting)
Parse a JS template with $placeholders, substitute values, and generate code.
Like Elixir's quote/unquote but for JavaScript:
js =
OXC.parse!("const $name = $value", "t.js")
|> OXC.bind(name: "count", value: {:literal, 0})
|> OXC.codegen!()
# "const count = 0;\n"Binding values can be:
- A string — replaces the identifier name
{:literal, value}— replaces with a literal node (string, number, boolean, nil)-
A map with
:type— splices a raw AST node
# Splice an AST node
expr = %{type: :binary_expression, operator: "+",
left: %{type: :literal, value: 1},
right: %{type: :literal, value: 2}}
js =
OXC.parse!("const result = $expr", "t.js")
|> OXC.bind(expr: expr)
|> OXC.codegen!()
# "const result = 1 + 2;\n"
Use .js/.ts files as templates with full editor support:
# priv/templates/api-client.js — real JS, full syntax highlighting
# import { z } from "zod";
# export const $schema = z.object($fields);
# export async function $listFn(params = {}) { ... }
template = File.read!("priv/templates/api-client.js")
ast = OXC.parse!(template, "api-client.js")
js =
ast
|> OXC.bind(schema: "userSchema", listFn: "listUsers", ...)
|> OXC.codegen!()Transform
Strip TypeScript types and transform JSX:
{:ok, js} = OXC.transform("const x: number = 42", "test.ts")
# "const x = 42;\n"
{:ok, js} = OXC.transform("<App />", "app.tsx")
# Uses automatic JSX runtime by default
{:ok, js} = OXC.transform("<App />", "app.jsx", jsx: :classic)
# Uses React.createElementWith source maps:
{:ok, %{code: js, sourcemap: map}} = OXC.transform(code, "app.ts", sourcemap: true)Target specific environments:
{:ok, js} = OXC.transform("const x = a ?? b", "test.js", target: "es2019")
# Nullish coalescing lowered to ternaryCustom JSX import source (Vue, Preact, etc.):
{:ok, js} = OXC.transform("<div />", "app.jsx", import_source: "vue")
# Imports from vue/jsx-runtime instead of react/jsx-runtimeMinify
{:ok, min} = OXC.minify("const x = 1 + 2; console.log(x);", "test.js")
# Constants folded, whitespace removed, variables mangled
{:ok, min} = OXC.minify(code, "test.js", mangle: false)
# Compress without renaming variablesLint
Lint JavaScript/TypeScript with oxlint's 650+ built-in rules:
{:ok, diags} = OXC.Lint.run("x == y", "test.js",
rules: %{"eqeqeq" => :deny})
# [%{rule: "eqeqeq", message: "Require the use of === and !==", severity: :deny, ...}]
{:ok, []} = OXC.Lint.run("export const x = 1;\n", "test.ts")Enable specific plugins:
{:ok, diags} = OXC.Lint.run(source, "app.tsx",
plugins: [:react, :typescript],
rules: %{"no-console" => :warn, "react/no-danger" => :deny})
Available plugins: :react, :typescript, :unicorn, :import, :jsdoc,
:jest, :vitest, :jsx_a11y, :nextjs, :react_perf, :promise,
:node, :vue, :oxc.
Custom Elixir Rules
Write project-specific lint rules in Elixir using the same AST from OXC.parse/2:
defmodule MyApp.NoConsoleLog do
@behaviour OXC.Lint.Rule
@impl true
def meta do
%{name: "my-app/no-console-log",
description: "Disallow console.log in production code",
category: :restriction, fixable: false}
end
@impl true
def run(ast, _context) do
OXC.collect(ast, fn
%{type: :call_expression,
callee: %{type: :member_expression,
object: %{type: :identifier, name: "console"},
property: %{type: :identifier, name: "log"}},
start: start, end: stop} ->
{:keep, %{span: {start, stop}, message: "Unexpected console.log"}}
_ -> :skip
end)
end
end
{:ok, diags} = OXC.Lint.run(source, "app.ts",
custom_rules: [{MyApp.NoConsoleLog, :warn}])Import Extraction
Fast NIF-level extraction of import specifiers — skips full AST serialization:
{:ok, imports} = OXC.imports("import { ref } from 'vue'\nimport { h } from 'preact'", "test.ts")
# ["vue", "preact"]Type-only imports are excluded automatically:
{:ok, imports} = OXC.imports("import type { Ref } from 'vue'\nimport { ref } from 'vue'", "test.ts")
# ["vue"]Typed Import Analysis
Collect imports with type information, byte offsets, and kind:
source = "import { ref } from 'vue'\nexport { foo } from './foo'\nimport('./lazy')"
{:ok, imports} = OXC.collect_imports(source, "test.js")
# [
# %{specifier: "vue", type: :static, kind: :import, start: 20, end: 25},
# %{specifier: "./foo", type: :static, kind: :export, start: 47, end: 54},
# %{specifier: "./lazy", type: :dynamic, kind: :import, start: 62, end: 70}
# ]Rewrite Specifiers
Rewrite import/export specifiers in a single pass without AST walking:
source = "import { ref } from 'vue'\nimport a from './utils'"
{:ok, result} = OXC.rewrite_specifiers(source, "test.js", fn
"vue" -> {:rewrite, "/@vendor/vue.js"}
_ -> :keep
end)
# "import { ref } from '/@vendor/vue.js'\nimport a from './utils'"
Handles ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, and dynamic import().
Validate
Fast syntax check without building an AST:
OXC.valid?("const x = 1", "test.js")
# true
OXC.valid?("const = ;", "bad.js")
# falseAST Traversal
{:ok, ast} = OXC.parse("import a from 'a'; import b from 'b'; const x = 1;", "test.js")
# Walk every node
OXC.walk(ast, fn
%{type: :identifier, name: name} -> IO.puts(name)
_ -> :ok
end)
# Collect specific nodes
imports = OXC.collect(ast, fn
%{type: :import_declaration} = node -> {:keep, node}
_ -> :skip
end)AST Postwalk and Source Patching
Rewrite source code by walking the AST and collecting byte-offset patches:
source = "import { ref } from 'vue'\nimport { h } from 'preact'"
{:ok, ast} = OXC.parse(source, "test.ts")
{_ast, patches} =
OXC.postwalk(ast, [], fn
%{type: :import_declaration, source: %{value: "vue", start: s, end: e}}, acc ->
{nil, [%{start: s, end: e, change: "'/@vendor/vue.js'"} | acc]}
node, acc ->
{node, acc}
end)
rewritten = OXC.patch_string(source, patches)
# "import { ref } from '/@vendor/vue.js'\nimport { h } from 'preact'"postwalk/2 visits nodes depth-first (children before parent), like Macro.postwalk/2.
postwalk/3 adds an accumulator for collecting data during traversal.
patch_string/2 applies patches in reverse offset order so positions stay valid.
All traversal functions (walk/2, postwalk/2, postwalk/3) accept either a single AST node or a list of nodes.
Bundle
Bundle multiple TypeScript/JavaScript modules into a single IIFE script. Treats the provided files as a virtual project, resolves their imports, transforms TS/JSX, and bundles the result:
files = [
{"event.ts", "export class Event { type: string; constructor(t: string) { this.type = t } }"},
{"target.ts", "import { Event } from './event'\nexport class Target extends Event {}"}
]
{:ok, js} = OXC.bundle(files, entry: "target.ts")Options:
# Minify with variable mangling
{:ok, js} = OXC.bundle(files, entry: "target.ts", minify: true)
# Tree-shaking (remove unused exports)
{:ok, js} = OXC.bundle(files, entry: "target.ts", treeshake: true)
# Inject code at the top of the IIFE body
{:ok, js} = OXC.bundle(files, entry: "app.ts", preamble: "const { ref } = Vue;")
# Compile-time replacements (like esbuild/Bun define)
{:ok, js} = OXC.bundle(files, entry: "target.ts", define: %{"process.env.NODE_ENV" => ~s("production")})
# Source maps
{:ok, %{code: js, sourcemap: map}} = OXC.bundle(files, entry: "target.ts", sourcemap: true)
# Output format: :iife (default), :esm, or :cjs
{:ok, js} = OXC.bundle(files, entry: "target.ts", format: :esm)
# Remove console.* calls
{:ok, js} = OXC.bundle(files, entry: "target.ts", minify: true, drop_console: true)
# Target-specific downleveling
{:ok, js} = OXC.bundle(files, entry: "target.ts", target: "es2020")
# Banner and footer
{:ok, js} = OXC.bundle(files, entry: "target.ts", banner: "/* MIT */", footer: "/* v1.0 */")Bang Variants
All functions have bang variants that raise OXC.Error on failure:
ast = OXC.parse!("const x = 1", "test.js")
js = OXC.transform!("const x: number = 42", "test.ts")
min = OXC.minify!("const x = 1 + 2;", "test.js")
js = OXC.codegen!(ast)
imports = OXC.imports!("import { ref } from 'vue'", "test.ts")Error Handling
All functions return {:ok, result} or {:error, errors} where errors are
maps with a :message key:
{:error, [%{message: "Expected a semicolon or ..."}]} = OXC.parse("const = ;", "bad.js")How It Works
OXC is a collection of high-performance JavaScript tools written in Rust.
This library wraps oxc_parser, oxc_transformer, oxc_minifier,
oxc_transformer_plugins, oxc_codegen, and oxc_linter via
Rustler NIFs, and uses
Rolldown/OXC for bundle/2.
All NIF calls run on the dirty CPU scheduler so they don't block the BEAM.
For parse, the parser produces ESTree JSON via OXC's serializer, Rustler encodes it as BEAM terms, and the Elixir wrapper normalizes AST keys to atoms with snake_case type values.
For codegen, the reverse happens: the Elixir AST map (BEAM terms) is
read directly by the NIF via Rustler's Term API, reconstructed into OXC's
arena-allocated AST using AstBuilder, and then emitted as JavaScript
via oxc_codegen.
For lint, oxlint's built-in rules run natively in Rust. Custom rules written in Elixir receive the same parsed AST and run in the BEAM.
License
MIT