Elixir bindings for the OXC JavaScript toolchain.

Provides fast JavaScript and TypeScript parsing, transformation, and minification via Rust NIFs. The file extension determines the dialect — .js, .jsx, .ts, .tsx.

iex> {:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
iex> ast.type
:program

iex> {:ok, js} = OXC.transform("const x: number = 42", "test.ts")
iex> js
"const x = 42;\n"

AST nodes are maps with atom keys, following the ESTree specification. The :type and :kind field values are snake_case atoms (e.g. :import_declaration, :variable_declaration).

Summary

Functions

Substitute $placeholders in an AST with provided values.

Bundle multiple TypeScript/JavaScript modules into a single IIFE script.

Like bundle/2 but raises on errors.

Generate JavaScript source code from an AST map.

Like codegen/1 but raises on errors.

Collect AST nodes that match a filter function.

Analyze imports with type information.

Extract import specifiers from JavaScript/TypeScript source.

Like imports/2 but raises on errors.

Minify JavaScript source code.

Like minify/3 but raises on errors.

Parse JavaScript or TypeScript source code into an ESTree AST.

Like parse/2 but raises on parse errors.

Apply patches to source code, like Sourceror.patch_string/2.

Depth-first post-order traversal, like Macro.postwalk/2.

Depth-first post-order traversal with accumulator, like Macro.postwalk/3.

Rewrite import/export specifiers in a single pass.

Replace $placeholder statements, properties, or elements with a list of nodes.

Transform TypeScript/JSX source code into plain JavaScript.

Transform multiple source files in parallel using a Rust thread pool.

Check if source code is syntactically valid.

Walk an AST tree, calling fun on every node (any map with a :type key).

Types

ast()

@type ast() :: %{:type => atom(), optional(atom()) => any()}

bundle_result()

@type bundle_result() ::
  {:ok, String.t() | code_with_sourcemap()} | {:error, [error()]}

code_with_sourcemap()

@type code_with_sourcemap() :: %{code: String.t(), sourcemap: String.t()}

error()

@type error() :: %{message: String.t()}

parse_result()

@type parse_result() :: {:ok, ast()} | {:error, [error()]}

patch()

@type patch() :: %{
  start: non_neg_integer(),
  end: non_neg_integer(),
  change: String.t()
}

transform_result()

@type transform_result() ::
  {:ok, String.t() | code_with_sourcemap()} | {:error, [error()]}

Functions

bind(ast, bindings)

@spec bind(
  ast(),
  keyword()
) :: ast()

Substitute $placeholders in an AST with provided values.

Walks the AST and replaces any identifier node whose name starts with $ with the corresponding value from bindings.

Binding values can be:

  • A string — replaced as an identifier name
  • {:literal, value} — replaced with a literal node (string, number, boolean, nil, map, or list — maps and lists are converted recursively into JS object/array expressions)
  • {:expr, code} — parsed as a JavaScript expression
  • A map with :type — spliced as a raw AST node

Examples

iex> {:ok, ast} = OXC.parse("const x = $value", "t.js")
iex> ast = OXC.bind(ast, value: {:literal, 42})
iex> OXC.codegen!(ast) =~ "const x = 42"
true

iex> {:ok, ast} = OXC.parse("const $name = 1", "t.js")
iex> ast = OXC.bind(ast, name: "myVar")
iex> OXC.codegen!(ast) =~ "const myVar = 1"
true

bundle(files, opts \\ [])

@spec bundle(
  [{String.t(), String.t()}],
  keyword()
) :: bundle_result()

Bundle multiple TypeScript/JavaScript modules into a single IIFE script.

Takes a list of {filename, source} tuples representing a virtual project. Modules can import each other via relative paths and are bundled into a single IIFE script.

Options

  • :entry — entry module filename from files (required), for example "main.ts"
  • :format — output format: :iife (default), :esm, or :cjs
  • :minify — minify the output (default: false)
  • :treeshake — enable tree-shaking to remove unused exports (default: false)
  • :banner — string to prepend before the IIFE (e.g. "/* v1.0 */")
  • :footer — string to append after the IIFE
  • :preamble — code to inject at the top of the IIFE function body, before any bundled modules (e.g. "const { ref } = Vue;")
  • :external — list of bare specifiers to treat as external and preserve as import statements in the output (e.g. ["react", "scheduler"]). Bare specifiers from ESM imports are auto-detected as external; use this for additional specifiers the auto-detection misses.
  • :define — compile-time replacements, map of %{"process.env.NODE_ENV" => ~s("production")}
  • :sourcemap — generate a source map (default: false). When true, returns %{code: String.t(), sourcemap: String.t()} instead of a plain string.
  • :drop_console — remove console.* calls during minification (default: false)
  • :jsx — JSX runtime, :automatic (default) or :classic
  • :jsx_factory — function for classic JSX (default: "React.createElement")
  • :jsx_fragment — fragment for classic JSX (default: "React.Fragment")
  • :import_source — JSX import source (e.g. "vue", "preact")
  • :target — downlevel target (e.g. "es2019", "chrome80")

Examples

iex> files = [
...>   {"event.ts", "export class Event { type: string; constructor(type: string) { this.type = type } }"},
...>   {"target.ts", "import { Event } from './event'\nexport class Target extends Event {}"}
...> ]
iex> {:ok, js} = OXC.bundle(files, entry: "target.ts")
iex> String.contains?(js, "Event")
true
iex> String.contains?(js, "Target")
true
iex> String.contains?(js, "import ")
false

bundle!(files, opts \\ [])

@spec bundle!(
  [{String.t(), String.t()}],
  keyword()
) :: String.t() | code_with_sourcemap()

Like bundle/2 but raises on errors.

codegen(ast)

@spec codegen(ast()) :: {:ok, String.t()} | {:error, [error()]}

Generate JavaScript source code from an AST map.

Takes an ESTree AST (as returned by parse/2 or constructed manually) and produces formatted JavaScript source code using OXC's code generator.

Handles operator precedence, indentation, and semicolon insertion.

Examples

iex> ast = OXC.parse!("const x = 1 + 2", "test.js")
iex> {:ok, js} = OXC.codegen(ast)
iex> js =~ "const x = 1 + 2"
true

iex> ast = %{type: :program, body: [
...>   %{type: :variable_declaration, kind: :const, declarations: [
...>     %{type: :variable_declarator,
...>       id: %{type: :identifier, name: "x"},
...>       init: %{type: :literal, value: 42}}
...>   ]}
...> ]}
iex> {:ok, js} = OXC.codegen(ast)
iex> js =~ "const x = 42"
true

codegen!(ast)

@spec codegen!(ast()) :: String.t()

Like codegen/1 but raises on errors.

collect(node, fun)

@spec collect(ast(), (map() -> {:keep, any()} | :skip)) :: [any()]

Collect AST nodes that match a filter function.

The function receives each node (map with :type key) and should return {:keep, value} to include it in results, or :skip to exclude it.

Examples

iex> {:ok, ast} = OXC.parse("const x = y + z", "test.js")
iex> OXC.collect(ast, fn
...>   %{type: :identifier, name: name} -> {:keep, name}
...>   _ -> :skip
...> end)
["x", "y", "z"]

collect_imports(source, filename)

@spec collect_imports(String.t(), String.t()) ::
  {:ok,
   [
     %{
       specifier: String.t(),
       type: :static | :dynamic,
       kind: :import | :export | :export_all,
       start: non_neg_integer(),
       end: non_neg_integer()
     }
   ]}
  | {:error, [error()]}

Analyze imports with type information.

Returns {:ok, list} where each element is a map with:

  • :specifier — the import source string (e.g. "vue", "./utils")
  • :type:static or :dynamic
  • :kind:import, :export, or :export_all
  • :start — byte offset of the specifier string literal (including quote)
  • :end — byte offset of the end of the specifier string literal

Type-only imports/exports (import type { ... }, export type { ... }) are excluded.

Examples

iex> source = "import { ref } from 'vue'\nexport { foo } from './bar'\nimport('./lazy')"
iex> {:ok, imports} = OXC.collect_imports(source, "test.js")
iex> Enum.map(imports, & &1.specifier)
["vue", "./bar", "./lazy"]
iex> Enum.map(imports, & &1.type)
[:static, :static, :dynamic]
iex> Enum.map(imports, & &1.kind)
[:import, :export, :import]

collect_imports!(source, filename)

@spec collect_imports!(String.t(), String.t()) :: [map()]

Like collect_imports/2 but raises on errors.

imports(source, filename)

@spec imports(String.t(), String.t()) :: {:ok, [String.t()]} | {:error, [error()]}

Extract import specifiers from JavaScript/TypeScript source.

Faster than parse/2 + collect/2 — skips full AST serialization and returns only the import source strings. Type-only imports (import type { ... }) are excluded.

Examples

iex> {:ok, imports} = OXC.imports("import { ref } from 'vue'\nimport type { Ref } from 'vue'", "test.ts")
iex> imports
["vue"]

imports!(source, filename)

@spec imports!(String.t(), String.t()) :: [String.t()]

Like imports/2 but raises on errors.

minify(source, filename, opts \\ [])

@spec minify(String.t(), String.t(), keyword()) ::
  {:ok, String.t()} | {:error, [error()]}

Minify JavaScript source code.

Applies dead code elimination, constant folding, and whitespace removal. Optionally mangles variable names for smaller output.

Options

  • :mangle — rename variables for shorter names (default: true)

Examples

iex> {:ok, min} = OXC.minify("if (false) { x() } y();", "test.js")
iex> min =~ "y()"
true
iex> min =~ "x()"
false

minify!(source, filename, opts \\ [])

@spec minify!(String.t(), String.t(), keyword()) :: String.t()

Like minify/3 but raises on errors.

Examples

iex> min = OXC.minify!("const x = 1 + 2;", "test.js")
iex> is_binary(min)
true

parse(source, filename)

@spec parse(String.t(), String.t()) :: parse_result()

Parse JavaScript or TypeScript source code into an ESTree AST.

The filename extension determines the dialect:

  • .js — JavaScript
  • .jsx — JavaScript with JSX
  • .ts — TypeScript
  • .tsx — TypeScript with JSX

Returns {:ok, ast} where ast is a map with atom keys, or {:error, errors} with a list of parse error maps.

Examples

iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> [decl] = ast.body
iex> decl.type
:variable_declaration

iex> {:error, [%{message: msg} | _]} = OXC.parse("const = ;", "bad.js")
iex> is_binary(msg)
true

parse!(source, filename)

@spec parse!(String.t(), String.t()) :: ast()

Like parse/2 but raises on parse errors.

Examples

iex> ast = OXC.parse!("const x = 1", "test.js")
iex> ast.type
:program

patch_string(source, patches)

@spec patch_string(String.t(), [patch()]) :: String.t()

Apply patches to source code, like Sourceror.patch_string/2.

Each patch is a map with :start (byte offset), :end (byte offset), and :change (replacement string). Patches are applied in reverse offset order so that earlier patches don't shift later offsets.

When multiple patches target the same {start, end} range, only the first one is applied and duplicates are silently dropped.

Use with postwalk/3 to collect patches from the AST, then apply them to the original source string.

Examples

iex> OXC.patch_string("hello world", [%{start: 6, end: 11, change: "elixir"}])
"hello elixir"

iex> source = "import { ref } from 'vue'"
iex> OXC.patch_string(source, [%{start: 20, end: 25, change: "'/@vendor/vue.js'"}])
"import { ref } from '/@vendor/vue.js'"

postwalk(nodes, fun)

@spec postwalk(ast() | [ast()], (map() -> map())) :: map() | [map()]

Depth-first post-order traversal, like Macro.postwalk/2.

Visits every AST node (map with a :type key). Children are visited first, then the node itself. The callback returns the (possibly modified) node.

Accepts a single AST node or a list of nodes.

Examples

iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> OXC.postwalk(ast, fn
...>   %{type: :identifier, name: "x"} = node -> %{node | name: "y"}
...>   node -> node
...> end)
iex> :ok
:ok

postwalk(nodes, acc, fun)

@spec postwalk(ast() | [ast()], acc, (map(), acc -> {map(), acc})) ::
  {map() | [map()], acc}
when acc: term()

Depth-first post-order traversal with accumulator, like Macro.postwalk/3.

The callback receives each AST node and the accumulator, and must return {node, acc}. Use this to collect data while traversing.

Accepts a single AST node or a list of nodes.

Examples

iex> source = "import { ref } from 'vue'\nimport a from './utils'"
iex> {:ok, ast} = OXC.parse(source, "test.ts")
iex> {_ast, patches} = OXC.postwalk(ast, [], fn
...>   %{type: :import_declaration, source: %{value: "vue"} = src} = node, patches ->
...>     {node, [%{start: src.start, end: src.end, change: "'/@vendor/vue.js'"} | patches]}
...>   node, patches ->
...>     {node, patches}
...> end)
iex> OXC.patch_string(source, patches)
"import { ref } from '/@vendor/vue.js'\nimport a from './utils'"

rewrite_specifiers(source, filename, fun)

@spec rewrite_specifiers(String.t(), String.t(), (String.t() ->
                                              {:rewrite, String.t()} | :keep)) ::
  {:ok, String.t()} | {:error, [error()]}

Rewrite import/export specifiers in a single pass.

Parses the source, finds all import/export declarations (ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, and dynamic ImportExpression), and calls fun with each specifier string.

The callback returns:

  • {:rewrite, new_specifier} — replace the specifier
  • :keep — leave unchanged

Returns {:ok, patched_source} or {:error, errors}.

Examples

iex> source = "import { ref } from 'vue'\nimport a from './utils'"
iex> {:ok, result} = OXC.rewrite_specifiers(source, "test.js", fn
...>   "vue" -> {:rewrite, "/@vendor/vue.js"}
...>   _ -> :keep
...> end)
iex> result
"import { ref } from '/@vendor/vue.js'\nimport a from './utils'"

rewrite_specifiers!(source, filename, fun)

@spec rewrite_specifiers!(String.t(), String.t(), (String.t() ->
                                               {:rewrite, String.t()} | :keep)) ::
  String.t()

Like rewrite_specifiers/3 but raises on errors.

splice(ast, name, replacement)

@spec splice(ast(), atom(), ast() | String.t() | [ast() | String.t()]) :: ast()

Replace $placeholder statements, properties, or elements with a list of nodes.

Finds expression statements, shorthand object properties, or array elements whose identifier name starts with $ and replaces them with the provided nodes. Accepts a single item or a list. Strings are auto-parsed as JS.

Examples

iex> {:ok, ast} = OXC.parse("function f() { $body }", "t.js")
iex> ast = OXC.splice(ast, :body, ["const x = 1;", "return x;"])
iex> js = OXC.codegen!(ast)
iex> js =~ "const x = 1" and js =~ "return x"
true

iex> {:ok, ast} = OXC.parse("const obj = {a: 1, $rest}", "t.js")
iex> ast = OXC.splice(ast, :rest, ["b: 2", "c: 3"])
iex> js = OXC.codegen!(ast)
iex> js =~ "b: 2" and js =~ "c: 3"
true

transform(source, filename, opts \\ [])

@spec transform(String.t(), String.t(), keyword()) :: transform_result()

Transform TypeScript/JSX source code into plain JavaScript.

Strips type annotations, transforms JSX, and lowers syntax features. The filename extension determines the source dialect.

Options

  • :jsx — JSX runtime, :automatic (default) or :classic
  • :jsx_factory — function for classic JSX (default: "React.createElement")
  • :jsx_fragment — fragment for classic JSX (default: "React.Fragment")
  • :import_source — JSX import source (e.g. "vue", "preact")
  • :target — downlevel target (e.g. "es2019", "chrome80")
  • :sourcemap — generate a source map (default: false). When true, returns %{code: String.t(), sourcemap: String.t()} instead of a plain string.

Examples

iex> {:ok, js} = OXC.transform("const x: number = 42", "test.ts")
iex> js
"const x = 42;\n"

iex> {:ok, js} = OXC.transform("<div />", "c.jsx", jsx: :classic)
iex> js =~ "createElement"
true

transform!(source, filename, opts \\ [])

@spec transform!(String.t(), String.t(), keyword()) ::
  String.t() | code_with_sourcemap()

Like transform/3 but raises on errors.

Examples

iex> OXC.transform!("const x: number = 42", "test.ts")
"const x = 42;\n"

transform_many(inputs, opts \\ [])

@spec transform_many(
  [{String.t(), String.t()}],
  keyword()
) :: [transform_result()]

Transform multiple source files in parallel using a Rust thread pool.

Accepts a list of {source, filename} tuples and shared options. Returns a list of results in the same order, each being {:ok, code}, {:ok, %{code: ..., sourcemap: ...}}, or {:error, errors}.

Significantly faster than calling transform/3 sequentially for many files, since work is distributed across OS threads without BEAM scheduling overhead.

Examples

iex> results = OXC.transform_many([{"const x: number = 1", "a.ts"}, {"const y: number = 2", "b.ts"}])
iex> length(results)
2
iex> {:ok, code} = hd(results)
iex> code =~ "const x = 1"
true

valid?(source, filename)

@spec valid?(String.t(), String.t()) :: boolean()

Check if source code is syntactically valid.

Faster than parse/2 — skips AST serialization.

Examples

iex> OXC.valid?("const x = 1", "test.js")
true

iex> OXC.valid?("const = ;", "bad.js")
false

walk(nodes, fun)

@spec walk(ast() | [ast()], (map() -> any())) :: :ok

Walk an AST tree, calling fun on every node (any map with a :type key).

Descends into all map values and list elements to reach nested AST nodes, including maps without a :type key (which are skipped for the callback but still traversed).

Examples

iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> OXC.walk(ast, fn
...>   %{type: :identifier, name: name} -> send(self(), {:id, name})
...>   _ -> :ok
...> end)
iex> receive do {:id, name} -> name end
"x"