JavaScript-Only Development

JavaScript-Only Development

This guide covers writing Ruby code that’s designed exclusively to run as JavaScript. This is different from “dual-target” code—here, JavaScript is the only target and the code won’t run in Ruby.

This approach is ideal for:

  • Browser applications and SPAs
  • Node.js tools and libraries
  • Browser extensions
  • CLI tools, runtimes, and scaffolding for self-hosted transpilers

Table of Contents

Why JavaScript-Only?

Try it — this example uses JavaScript APIs directly:

export class Counter
  def initialize(element)
    @element = element
    @count = 0
    @element.addEventListener('click') { increment() }
  end

  def increment()
    @count += 1
    @element.textContent = "Count: #{@count}"
  end
end

Ruby2JS produces idiomatic JavaScript without a runtime. Combined with Ruby’s cleaner syntax, you get:

  • Better syntax - blocks, unless, guard clauses, implicit returns
  • Familiar patterns - Ruby’s object model maps well to JavaScript
  • Full JS access - call any JavaScript API directly
  • No runtime overhead - output is plain JavaScript
  • Better tooling - use Ruby editors, formatters, and linters

Key Differences from Dual-Target

Aspect Dual-Target JavaScript-Only
Primary runtime Both Ruby and JS JavaScript only
Ruby execution Production use None
JS APIs Avoided or wrapped Used directly
Ruby-only code Minimized Skipped liberally
Pragmas Occasional Common

Calling JavaScript APIs

Direct API Access

Call JavaScript APIs as if they were Ruby methods:

# DOM manipulation
element = document.getElementById('app')
element.addEventListener('click') { |e| handle(e) }
element.classList.add('active')

# Console and debugging
console.log('Debug:', data)

# Modern JS APIs
data = await fetch('/api/users').then { |r| r.json() }

Constructor Calls

JavaScript’s new keyword works naturally. Ruby2JS preserves whether you use parentheses—new Date vs new Date(). While functionally equivalent for no-argument constructors, parentheses affect operator precedence (e.g., new Date().getTime() works but new Date.getTime() tries to construct Date.getTime):

date = Date.new       # new Date
date = Date.new()     # new Date()
url = URL.new(path, base_url)
arr = Uint8Array.new(buffer, offset, length)

Note: Some JavaScript built-ins have special rules. Ruby2JS knows that Symbol() must not use new, while Promise, Map, and Set require it. Call Symbol("name") directly without .new.

JavaScript Operators

The functions filter provides direct access to JavaScript operators:

# typeof operator
type = typeof(value)

# debugger statement
debugger

Global Objects

Access globalThis, window, document, etc.:

# Browser globals
location = window.location.href
document.body.style.background = 'red'

# Node.js globals
args = process.argv[2..-1]
debug = process.env.DEBUG

# Universal global
globalThis.MyLib = my_module

Module System

ES Module Imports

# Default import
import React, from: 'react'

# Named imports
import [useState, useEffect], from: 'react'

# Namespace import
import "*", as: Prism, from: '@ruby/prism'

# Side-effect import
import 'styles.css'

# Dynamic import
mod = await import('./module.js')

ES Module Exports

export class MyClass
  def process(x)
    x * 2
  end
end

export def helper(x)
  x * 2
end

export DEFAULT_VALUE = 42

Async/Await

Ruby2JS supports async/await naturally:

async def fetch_user(id)
  response = await fetch("/api/users/#{id}")
  await response.json()
end

async def load_data
  # Parallel fetches
  users, posts = await Promise.all([
    fetch_user(1),
    fetch_posts()
  ])
  { users: users, posts: posts }
end

First-Class Functions

In JavaScript, functions are first-class citizens and can be called directly with parentheses. In Ruby, procs and lambdas require .call() or .(). For JavaScript-only code, you can skip the Ruby ceremony and call functions directly:

# Lambda becomes arrow function
double = ->(x) { x * 2 }

# Ruby style (works but unnecessary)
result = double.call(5)
result = double.(5)

# JavaScript style (preferred for JS-only)
result = double(5)

All three Ruby syntaxes (proc, lambda, ->) become JavaScript arrow functions. For JavaScript-only code, prefer calling them directly with parentheses.

Patterns for JavaScript-Only Code

Entry Point Guard

For modules that can be both imported and run directly:

import [fileURLToPath], from: 'url'

def main
  console.log("Running as CLI")
end

# Only run when executed directly (not imported)
if process.argv[1] == fileURLToPath(import.meta.url)
  main()
end

Optional Chaining

Use Ruby’s safe navigation operator:

# Ruby's &. becomes JavaScript's ?.
name = user&.profile&.name
count = data&.items&.length

Nullish Coalescing

Use pragmas to control || vs ??:

# When 0 or "" are valid values, use ??
count ||= 0 # Pragma: ??

# When you need falsy-check (false, 0, ""), use ||
enabled ||= true # Pragma: logical

Property Access vs Method Calls

Ruby2JS uses parentheses to distinguish between property access and method calls. This is one of the most important concepts for JavaScript-only development.

# No parens = property access (getter)
len = obj.length
first = arr.first

# Empty parens = method call
item = list.pop()
result = obj.process()

# Parens with args = always method call
obj.set(42)

This applies to your own methods too. When defining methods:

  • def foo — becomes a getter, accessed as obj.foo
  • def foo() — becomes a method, called as obj.foo()

When calling methods (especially in callbacks):

class Widget
  def setup()
    # WRONG: increment without parens just returns the getter
    # @button.addEventListener('click') { increment }

    # RIGHT: use parens to actually call the method
    @button.addEventListener('click') { increment() }
  end

  def increment()
    @count += 1
  end
end

Skipping Ruby-Only Code

Use # Pragma: skip liberally:

require 'json' # Pragma: skip

def to_sexp # Pragma: skip
  # Ruby debugging only
end

def process(data)
  data.map { |x| x * 2 }
end

Type Disambiguation

When Ruby2JS can’t infer types:

# Array operations
items << item # Pragma: array

# Hash/Object operations
options.each { |k, v| process(k, v) } # Pragma: entries

# First-class functions
handler.call(args) # Pragma: method

Real Example: Self-Hosted CLI

The Ruby2JS project uses this approach for its self-hosted CLI and runtime scaffolding. Here’s a simplified example from the source buffer implementation:

import "*", as: Prism, from: '@ruby/prism'

export class SourceBuffer
  def initialize(source, file)
    @source = source
    @name = file || '(eval)'
    @lineOffsets = [0]
    i = 0
    while i < source.length
      @lineOffsets.push(i + 1) if source[i] == "\n"
      i += 1
    end
  end

  attr_reader :source, :name

  def lineForPosition(pos)
    idx = @lineOffsets.findIndex { |offset| offset > pos }
    idx == -1 ? @lineOffsets.length : idx
  end
end

Notice:

  • Direct use of JavaScript APIs (findIndex, push)
  • ES module syntax (import, export)
  • Ruby control flow (while, if modifier)
  • Instance variables as properties

Bundling with Require Filter

For larger projects, split code across files and use the Require filter:

# bundle.rb - entry point
require_relative 'runtime'
require_relative 'parser'
require_relative 'converter'

export [Parser, Converter]

The Require filter inlines these files, producing a single bundled JavaScript module.

Skip External Dependencies

require 'json' # Pragma: skip
require_relative 'helper'  # This gets inlined

Filter Configuration

Recommended filters for JavaScript-only development:

Ruby2JS.convert(source,
  eslevel: 2022,
  comparison: :identity,      # == becomes ===
  underscored_private: true,  # @foo becomes this._foo
  filters: [
    Ruby2JS::Filter::Pragma,    # Line-level control
    Ruby2JS::Filter::Require,   # File inlining
    Ruby2JS::Filter::Functions, # Ruby→JS method mapping
    Ruby2JS::Filter::Return,    # Implicit returns
    Ruby2JS::Filter::ESM        # ES modules
  ]
)

Common Gotchas

.to_s vs .toString()

The functions filter converts .to_s to .toString(). When you need the literal .to_s method:

# This becomes .toString()
str = obj.to_s

# Use bang to keep as .to_s()
str = obj.to_s!

Arrow Functions and this

Blocks become arrow functions, which capture this lexically:

# Arrow function - this is outer scope
element.on("click") { handle(this) }

# Traditional function - this is the element
element.on("click") { handle(this) } # Pragma: noes2015

Import Hoisting

Ruby2JS hoists imports to the top of the output. Be aware when inlining multiple files—duplicate imports may appear. This is a known area for improvement.

Property Getters

Methods without parentheses become getters:

class Token
  def initialize(text)
    @text = text
  end

  # Becomes a getter, accessed as: token.text
  def text
    @text
  end

  # Empty parens = method call: token.getText()
  def getText()
    @text
  end
end

See Also

Next: ActiveFunctions