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
- JavaScript-Only Development
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 asobj.foodef foo()— becomes a method, called asobj.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,ifmodifier) - 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
- ESM Filter - ES module syntax
- Require Filter - File bundling
- Functions Filter - Ruby→JS mappings
- Pragmas - Line-level control