Basics & Output
Hello, World
Elm has no
print, and in fact no statements at all — you define main as an expression, and the runtime renders whatever it evaluates to.module Main exposing (main)
import Html exposing (text)
main =
text "Hello, World!"
puts "Hello, World!" Ruby is statement-oriented and effect-first:
puts writes straight to standard output, which this column captures as text. Where Elm describes a value for the runtime to render, Ruby just performs the side effect on the spot.Comments
Elm has two comment forms — a line comment and a block comment — both shown here inside
main.module Main exposing (main)
import Html exposing (text)
main =
-- a single-line comment
{- a
multi-line comment
-}
text "commented"
# a single-line comment
=begin
a
multi-line comment
=end
puts "commented" Ruby writes a line comment with
#; its block comment is the =begin/=end pair, which — unlike Elm's {- -} — must start in the first column and cannot nest. Ruby has no dedicated documentation-comment syntax the way Elm has {-| ... -}.Local bindings
A
let ... in block names intermediate values for use in one expression; every binding is immutable.module Main exposing (main)
import Html exposing (text)
main =
let
greeting =
"Hello"
name =
"Elm"
in
text (greeting ++ ", " ++ name ++ "!")
greeting = "Hello"
name = "Ruby"
puts "#{greeting}, #{name}!" Ruby has no
let and no block scope for locals — a variable simply springs into being on assignment and stays reassignable. Nothing marks it immutable, so the discipline Elm enforces is left to convention here.Types & Duck Typing
Type annotations
The line
double : Int -> Int above the function is its type signature — read it as "takes an Int, returns an Int".module Main exposing (main)
import Html exposing (text)
double : Int -> Int
double n =
n * 2
main =
text (String.fromInt (double 21))
def double(number) = number * 2
puts double(21) Ruby has no inline type annotations and no compile-time checking; a method just takes whatever it is given. Optional
.rbs signature files plus a tool like Steep can add external checking, but nothing in the language requires it. This also shows Ruby's "endless" one-line method (def name(...) = expression), the closest match to Elm's single-expression bodies.Dynamic typing
With no annotation written, Elm still works out the exact type of every value here and checks it before the program runs.
module Main exposing (main)
import Html exposing (text)
main =
let
numbers =
List.map (\n -> n * n) (List.range 1 4)
in
text (Debug.toString numbers)
numbers = (1..4).map { |number| number * number }
p numbers Ruby infers nothing and checks nothing ahead of time — a type mismatch surfaces only when the offending line runs, if it runs at all. In exchange, everything is genuinely an object: even
42 and nil have methods (42.even?, nil.to_s). The p method prints any value's inspect form, filling the role of Debug.toString.Numbers & Strings
Int vs Float
Elm keeps
Int and Float as separate types with separate division operators — watch which one each line uses.module Main exposing (main)
import Html exposing (text)
main =
let
wholeHalves =
7 // 2
exactHalves =
7 / 2
in
text (String.fromInt wholeHalves ++ " and " ++ String.fromFloat exactHalves)
whole_halves = 7 / 2
exact_halves = 7.fdiv(2)
puts "#{whole_halves} and #{exact_halves}" Ruby also distinguishes
Integer from Float, but chooses the operation by the operands rather than the operator: 7 / 2 is integer division (3) because both are integers, and you ask for a float result with 7.fdiv(2) or 7.0 / 2. Mixing the two never fails to compile — there is no compile step — so a stray .0 silently changes the answer.String operations
Elm's string tools are functions in the
String module, not methods on the string itself.module Main exposing (main)
import Html exposing (text)
main =
let
shout =
String.toUpper "hello"
size =
String.length "hello"
in
text (shout ++ " has " ++ String.fromInt size ++ " letters")
shout = "hello".upcase
size = "hello".length
puts "#{shout} has #{size} letters" Ruby exposes the same operations as methods called on the string object —
"hello".upcase, "hello".length — because in Ruby a string is an object with behavior, not a value acted on by a separate module. In Ruby 4.0 string literals are frozen by default, so upcase returns a new string rather than mutating in place, matching Elm's immutability.Building strings
Elm has no string interpolation: the pieces are joined with
++, and numbers must be converted to strings explicitly.module Main exposing (main)
import Html exposing (text)
main =
let
name =
"Elm"
year =
2012
in
text (name ++ " first appeared in " ++ String.fromInt year)
name = "Ruby"
year = 1995
puts "#{name} first appeared in #{year}" Ruby interpolates with
#{...} inside a double-quoted string and silently calls to_s on whatever you embed — so the integer year needs no explicit conversion. That convenience is exactly what Elm withholds on purpose: its explicit String.fromInt guarantees you never coerce a value by accident.Splitting & joining
String.split breaks a string into a list and String.join reassembles one — both are ordinary functions.module Main exposing (main)
import Html exposing (text)
main =
"a,b,c"
|> String.split ","
|> String.join " | "
|> text
puts "a,b,c".split(",").join(" | ") Ruby spells these as methods chained off the value —
split returns an Array and join folds it back to a String. Method chaining reads much like Elm's |> pipeline, but only for methods the receiving object actually defines, whereas |> threads a value through any function.Booleans & Conditionals
if is an expression
In Elm,
if is an expression that produces a value — which is why it always needs an else.module Main exposing (main)
import Html exposing (text)
classify : Int -> String
classify n =
if n > 0 then
"positive"
else if n < 0 then
"negative"
else
"zero"
main =
text (classify -5)
def classify(number)
if number > 0
"positive"
elsif number < 0
"negative"
else
"zero"
end
end
puts classify(-5) Ruby's
if is also an expression — the method returns the chosen branch with no return needed, a rare point of agreement with Elm. The difference is that Ruby's else is optional: an if with no matching branch evaluates to nil, and the branches need not share a type, so nothing rules out the "missing else" that Elm forbids.Boolean logic & truthiness
Elm's
&&, ||, and not accept only the Bool type — there are no truthy or falsy values.module Main exposing (main)
import Html exposing (text)
main =
let
ready =
True && not False
either =
True || False
in
text (Debug.toString (ready && either))
ready = true && !false
either = true || false
p(ready && either)
# Only nil and false are falsy — 0 and "" are truthy:
p([0, "", nil].map { |value| value ? "truthy" : "falsy" }) Ruby treats every value as a boolean in a condition, and — unlike JavaScript — only
nil and false are falsy, so 0 and "" are truthy. Its &&/|| return one of their operands rather than a strict Bool, which enables idioms like value || default. Elm rejects all of this: a condition must be an actual Bool.Maybe vs nil
Absence of a value
Elm has no
null. A value that might be missing has the type Maybe — either Just x or Nothing.module Main exposing (main)
import Html exposing (text)
lookup : String -> Maybe Int
lookup key =
if key == "answer" then
Just 42
else
Nothing
main =
text (String.fromInt (Maybe.withDefault 0 (lookup "answer")))
def lookup(key) = key == "answer" ? 42 : nil
puts(lookup("answer") || 0) Ruby uses
nil for a missing value, and value || default supplies a fallback much as Maybe.withDefault does. The crucial difference is that nil is not a distinct type: any value can be nil, and nothing forces you to handle that case, so a forgotten check becomes a NoMethodError on nil at runtime — the very class of bug Maybe makes impossible.Transforming a maybe-value
Maybe.map reaches inside a Just to transform its value and leaves a Nothing untouched — no unwrapping, no null check.module Main exposing (main)
import Html exposing (text)
firstName : List String -> Maybe String
firstName names =
List.head names
main =
text (Maybe.withDefault "(none)" (Maybe.map String.toUpper (firstName [ "ada", "grace" ])))
def first_name(names) = names.first
name = first_name(["ada", "grace"])
puts(name&.upcase || "(none)") Ruby's safe-navigation operator
&. calls a method only when the receiver is non-nil, so name&.upcase mirrors Maybe.map String.toUpper. But Array#first returns a bare nil on an empty array rather than a Nothing you are required to handle, so remembering the &. is on you — the emptiness is invisible in the type.Chaining that can fail
When one step's result can itself be missing,
Maybe.andThen chains the steps and stops at the first Nothing.module Main exposing (main)
import Html exposing (text)
parsePositive : String -> Maybe Int
parsePositive raw =
String.toInt raw
|> Maybe.andThen
(\n ->
if n > 0 then
Just n
else
Nothing
)
main =
text (Debug.toString (parsePositive "7"))
def parse_positive(raw)
number = Integer(raw, exception: false)
number && number.positive? ? number : nil
end
p parse_positive("7") Ruby threads possible absence by hand:
Integer(raw, exception: false) returns nil instead of raising on bad input, and the following number && ... short-circuits so nothing is called on nil. It works, but nothing composes the steps for you the way Maybe.andThen does, and no type records that the result can be missing.Collections
Transforming a list
The pipe operator
|> feeds a value into the next function, so this chain of list transforms reads from the top down.module Main exposing (main)
import Html exposing (text)
main =
[ 1, 2, 3, 4, 5 ]
|> List.filter (\n -> modBy 2 n == 0)
|> List.map (\n -> n * n)
|> List.map String.fromInt
|> String.join ", "
|> text
result = [1, 2, 3, 4, 5]
.select { |number| number.even? }
.map { |number| number * number }
.join(", ")
puts result Ruby chains methods on the collection itself —
select is Elm's List.filter and map matches directly. Each returns a new array, so the pipeline never mutates the original; the block { |number| ... } is Ruby's inline anonymous function, and number.even? replaces modBy 2 n == 0.Folding a list
A fold collapses an entire list into a single value;
List.foldl works from the left, carrying an accumulator.module Main exposing (main)
import Html exposing (text)
main =
let
total =
List.foldl (+) 0 [ 10, 20, 30 ]
in
text (String.fromInt total)
total = [10, 20, 30].reduce(0) { |sum, number| sum + number }
puts total
# For this common case Ruby also has a dedicated method:
puts [10, 20, 30].sum Ruby's
reduce (also spelled inject) is the counterpart to List.foldl, with the accumulator passed first into the block. You can even hand it a bare operator as a symbol — reduce(:+) — echoing Elm's (+). For summing specifically, the purpose-built sum is the idiomatic choice.Dict vs Hash
A
Dict is an immutable key–value map; looking up a key returns a Maybe, since the key may be absent.module Main exposing (main)
import Html exposing (text)
import Dict
main =
let
ages =
Dict.fromList [ ( "Alice", 30 ), ( "Bob", 25 ) ]
in
text (Debug.toString (Dict.get "Alice" ages))
ages = { "Alice" => 30, "Bob" => 25 }
puts ages["Alice"]
# A missing key is a silent nil unless you demand it exists:
puts ages.fetch("Carol", "unknown") Ruby's
Hash holds keys of any type at once and returns a bare nil for a missing key with [], rather than the Maybe that Elm's Dict.get forces you to unwrap. Use fetch when a miss should raise or take an explicit default. A Hash is also freely mutable, unlike an Elm Dict.Tuples & multiple return
A tuple groups a fixed number of values, each possibly a different type, and is taken apart by pattern — the idiomatic way to return two results at once.
module Main exposing (main)
import Html exposing (text)
divide : Int -> Int -> ( Int, Int )
divide numerator denominator =
( numerator // denominator, modBy denominator numerator )
main =
let
( quotient, remainder ) =
divide 17 5
in
text (String.fromInt quotient ++ " remainder " ++ String.fromInt remainder)
def divide(numerator, denominator)
[numerator / denominator, numerator % denominator]
end
quotient, remainder = divide(17, 5)
puts "#{quotient} remainder #{remainder}" Ruby has no tuple type; it returns an
Array and relies on parallel assignment (quotient, remainder = ...) to take it apart. The syntax is nearly identical to Elm's, but an array carries neither a fixed length nor per-position types, so the shape of the return value is not guaranteed the way a ( Int, Int ) is.Sorting a list
List.sort orders a list by the natural ordering of its element type, so a list of numbers sorts numerically on its own.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (List.sort [ 10, 2, 33, 4 ]))
p [10, 2, 33, 4].sort Ruby's
sort also uses the elements' natural order — numbers compare numerically, with none of JavaScript's convert-to-string surprise — and returns a brand-new array, leaving the original untouched, just as Elm does. (The in-place variant sort! is the mutating exception Ruby offers and Elm does not.)Records vs Objects
Defining a record
A record is a value with named fields whose set and types are fixed and known to the compiler, declared here as a
type alias.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String, age : Int }
main =
let
alice : Person
alice =
{ name = "Alice", age = 30 }
in
text (alice.name ++ " is " ++ String.fromInt alice.age)
Person = Data.define(:name, :age)
alice = Person.new(name: "Alice", age: 30)
puts "#{alice.name} is #{alice.age}" Ruby's closest match to an Elm record is
Data.define (Ruby 3.2+), which builds an immutable value object with named readers. It is a much tighter fit than a plain Hash: instances are frozen and compared by value. What it does not give you is field-level type checking — age can hold anything — nor a compile error for reading a field that does not exist.Updating a record
Nothing mutates in Elm, so "updating" a record means building a new one with the
{ record | field = ... } syntax.module Main exposing (main)
import Html exposing (text)
main =
let
alice =
{ name = "Alice", age = 30 }
older =
{ alice | age = alice.age + 1 }
in
text (older.name ++ " is now " ++ String.fromInt older.age)
Person = Data.define(:name, :age)
alice = Person.new(name: "Alice", age: 30)
older = alice.with(age: alice.age + 1)
puts "#{older.name} is now #{older.age}" A
Data object is immutable, so it offers with — the direct analog of Elm's record-update syntax, returning a fresh copy with some fields changed and the original intact. Like Elm's form it only sets fields that already exist, so a misspelled name raises rather than quietly adding a field.Custom Types
Enumerations
A custom type lists a fixed set of named alternatives — Elm's version of an enum — and the compiler checks that every
case handles all of them.module Main exposing (main)
import Html exposing (text)
type Light
= Red
| Yellow
| Green
next : Light -> Light
next light =
case light of
Red ->
Green
Yellow ->
Red
Green ->
Yellow
main =
text (Debug.toString (next Red))
def next_light(light)
case light
in :red then :green
in :yellow then :red
in :green then :yellow
end
end
p next_light(:red) Ruby has no enum type; the idiomatic stand-in is a symbol — an interned, identity-compared name like
:red — matched with case/in. But the set of symbols is open and unchecked: forgetting :green is not a compile error, it is a NoMatchingPatternError raised at runtime, so exhaustiveness is a hope rather than a guarantee.Variants that carry data
Beyond a plain enum, each alternative of a custom type can carry its own data, making it a true tagged union; a single
case both selects the alternative and destructures its data.module Main exposing (main)
import Html exposing (text)
type Shape
= Circle Float
| Rectangle Float Float
area : Shape -> Float
area shape =
case shape of
Circle radius ->
pi * radius * radius
Rectangle width height ->
width * height
main =
text (String.fromFloat (area (Rectangle 3 4)))
Circle = Data.define(:radius)
Rectangle = Data.define(:width, :height)
def area(shape)
case shape
in Circle(radius:) then Math::PI * radius * radius
in Rectangle(width:, height:) then width * height
end
end
puts area(Rectangle.new(width: 3, height: 4)) Ruby models a tagged union as one
Data class per variant, and — because Data supports deconstruction — case/in can match the class and pull out its fields in a single pattern, e.g. in Circle(radius:). It reads remarkably like Elm's case, but the variants are unrelated classes with no shared parent type, and the match is still checked only at runtime.Pattern Matching
Matching on a list
A
case expression picks a branch by matching a value's shape; here it splits a list into empty, single-item, and head-and-tail.module Main exposing (main)
import Html exposing (text)
describe : List Int -> String
describe numbers =
case numbers of
[] ->
"empty"
[ only ] ->
"one item: " ++ String.fromInt only
first :: rest ->
"starts with " ++ String.fromInt first ++ ", then " ++ String.fromInt (List.length rest) ++ " more"
main =
text (describe [ 1, 2, 3 ])
def describe(numbers)
case numbers
in [] then "empty"
in [only] then "one item: #{only}"
in [first, *rest] then "starts with #{first}, then #{rest.length} more"
end
end
puts describe([1, 2, 3]) Ruby 3's
case/in brings real structural matching: array patterns like [only] and [first, *rest] line up almost exactly with Elm's [ only ] and first :: rest, binding as they match. The gap is exhaustiveness — Ruby will not warn that a shape is unhandled, and an unmatched value raises at runtime instead of failing to compile.Matching an optional value
Because
Maybe is an ordinary custom type, a single case handles its Just and Nothing branches directly.module Main exposing (main)
import Html exposing (text)
greet : Maybe String -> String
greet maybeName =
case maybeName of
Just name ->
"Hello, " ++ name
Nothing ->
"Hello, stranger"
main =
text (greet (Just "Ada"))
def greet(name)
case name
in nil then "Hello, stranger"
in found then "Hello, #{found}"
end
end
puts greet("Ada") Ruby matches
nil as a literal pattern and binds anything else with a variable pattern like found. It captures the two Elm branches, but note the asymmetry: Just "Ada" and Nothing are two constructors of one type, whereas Ruby is matching a value that happens to be a string against one that happens to be nil, with nothing tying them into a single Maybe.Matching with guards
Group values in a tuple and one
case can match them together, mixing exact values with variables.module Main exposing (main)
import Html exposing (text)
quadrant : ( Int, Int ) -> String
quadrant point =
case point of
( 0, 0 ) ->
"origin"
( x, y ) ->
if x > 0 && y > 0 then
"upper-right"
else
"elsewhere"
main =
text (quadrant ( 3, 4 ))
def quadrant(point)
case point
in { x: 0, y: 0 } then "origin"
in { x:, y: } if x.positive? && y.positive? then "upper-right"
else "elsewhere"
end
end
puts quadrant({ x: 3, y: 4 }) Ruby's hash patterns match by key and bind the values (
{ x:, y: } binds x and y), and a trailing if adds a guard — a test Elm would push into an if inside the branch. Ruby also offers a plain else for the fall-through, where Elm insists every case be covered by a pattern.Functions & Blocks
Defining functions
An Elm function definition uses no
function keyword, no parentheses around its parameters, and no return; the body is a single expression.module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add a b =
a + b
main =
text (String.fromInt (add 2 3))
def add(first, second) = first + second
puts add(2, 3) Ruby's "endless" method (
def add(...) = expression, added in 3.0) matches Elm's single-expression style closely. A conventional def ... end body also returns its last expression with no return keyword needed. Ruby does wrap parameters in parentheses and separates arguments with commas at the call site — add(2, 3) — where Elm uses spaces.Anonymous functions
An anonymous function is introduced by a backslash before its parameter — a lambda with no name.
module Main exposing (main)
import Html exposing (text)
main =
let
triple =
\n -> n * 3
in
text (Debug.toString (List.map triple [ 1, 2, 3 ]))
triple = ->(number) { number * 3 }
p [1, 2, 3].map(&triple) Ruby writes a lambda as
->(number) { ... }, a first-class object you can store and pass around like any Elm function. Passing it where a block is expected takes an explicit & (map(&triple)), because Ruby draws a line — which Elm does not — between an ordinary object argument and the single "block" a method may receive.Higher-order functions & blocks
A higher-order function takes another function as an argument; in Elm you simply pass one function to another, since functions are ordinary values.
module Main exposing (main)
import Html exposing (text)
applyTwice : (Int -> Int) -> Int -> Int
applyTwice f x =
f (f x)
main =
text (String.fromInt (applyTwice (\n -> n + 3) 10))
def apply_twice(value)
yield(yield(value))
end
puts apply_twice(10) { |number| number + 3 } Ruby gives every method one special implicit callback — the block — invoked with
yield and written in { } or do ... end after the call. It is the idiom behind each, map, and friends. Elm has no such special slot: a function argument is passed and named like any other (applyTwice f x), which is why Elm needs no yield.Currying & partial application
Elm functions take one argument at a time, so applying
add to a single value yields a new, more specific function — partial application is free and pervasive.module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add a b =
a + b
main =
let
addTen =
add 10
in
text (Debug.toString (List.map addTen [ 1, 2, 3 ]))
add = ->(first, second) { first + second }
add_ten = add.curry[10]
p [1, 2, 3].map(&add_ten) Ruby methods are not curried by default —
add(10) would be an argument-count error — so partial application is opt-in: Proc#curry turns a lambda into one that accepts its arguments one at a time, and add.curry[10] then reads like Elm's add 10. Where currying is the default in Elm, it is an explicit tool in Ruby.Function composition
The
>> operator joins two functions into one that runs the first and passes its result to the second.module Main exposing (main)
import Html exposing (text)
main =
let
shoutReversed =
String.reverse >> String.toUpper
in
text (shoutReversed "elm")
reverse = ->(text) { text.reverse }
shout = ->(text) { text.upcase }
shout_reversed = reverse >> shout
puts shout_reversed.call("ruby") Ruby borrows the very same operator:
Proc#>> composes two callables left-to-right, so reverse >> shout reverses then upper-cases exactly as in Elm (and << composes the other way). The one catch is that composition works on Proc/lambda objects, so a plain method must first be wrapped with method(:name).Recursion & loops
Elm has no
for or while loop, so a repeated computation is written as a function that calls itself.module Main exposing (main)
import Html exposing (text)
factorial : Int -> Int
factorial n =
if n <= 1 then
1
else
n * factorial (n - 1)
main =
text (String.fromInt (factorial 5))
def factorial(number)
number <= 1 ? 1 : number * factorial(number - 1)
end
puts factorial(5) Ruby supports the same recursive definition, but unlike Elm it also offers real mutable loops (
while, for, times) and mutation, so idiomatic Ruby often reaches for iteration or a method like reduce instead. Ruby does not guarantee tail-call optimization, so very deep recursion can overflow the stack where a rewritten loop would not.Error Handling
Result vs exceptions
A computation that can fail returns a
Result — Ok value on success, Err reason on failure. Elm has no exceptions.module Main exposing (main)
import Html exposing (text)
safeDivide : Float -> Float -> Result String Float
safeDivide numerator denominator =
if denominator == 0 then
Err "cannot divide by zero"
else
Ok (numerator / denominator)
main =
text (Debug.toString (safeDivide 10 2))
def safe_divide(numerator, denominator)
raise ArgumentError, "cannot divide by zero" if denominator.zero?
numerator.fdiv(denominator)
end
begin
puts safe_divide(10, 2)
rescue ArgumentError => error
puts error.message
end Ruby signals failure the way Elm deliberately avoids: it
raises an exception that unwinds the stack until a begin/rescue catches it. The failure is control flow, not a value, so it never appears in a signature and a caller can simply forget to rescue — whereupon the error escapes to the top level. Elm makes the failure an ordinary Result value the caller is forced to handle.Recovering with a fallback
Result.map transforms a success value and carries any earlier Err straight through the chain untouched, so a whole pipeline runs with no nested checks.module Main exposing (main)
import Html exposing (text)
main =
let
parsed =
String.toInt "20"
|> Result.fromMaybe "not a number"
|> Result.map (\n -> n * 2)
in
text (Debug.toString parsed)
doubled = Integer("20") * 2 rescue "not a number"
p doubled Ruby's inline
rescue modifier supplies a fallback when an expression raises — here Integer("20") succeeds, but a non-numeric string would be rescued to "not a number", playing the role of Result.withDefault. It is compact, but it recovers by catching a stack unwind rather than by threading an Err value through the computation, and it will swallow any StandardError, not just the one you had in mind.Rendering HTML
Rendering a styled card
Both columns build the same card and render it live in the pane below. Elm builds HTML as a value with view functions; on the Ruby side, pick ERB (the classic Rails template) or Phlex (a pure-Ruby component) with the selector in the upper-right.
module Main exposing (main)
import Html exposing (Html, div, h2, p, text)
import Html.Attributes exposing (style)
main : Html msg
main =
div
[ style "padding" "16px"
, style "border-radius" "10px"
, style "background" "#5a3fc0"
, style "color" "white"
, style "font-family" "system-ui, sans-serif"
]
[ h2 [ style "margin" "0 0 6px" ] [ text "Elm renders this" ]
, p [ style "margin" "0" ] [ text "Built from the Html view function." ]
] require "erb"
title = "Ruby"
template = <<~ERB
<div style="padding:16px;border-radius:10px;background:#cc342d;color:white;font-family:system-ui,sans-serif">
<h2 style="margin:0 0 6px"><%= title %> renders this</h2>
<p style="margin:0">Built from an ERB template string.</p>
</div>
ERB
print ERB.new(template).result(binding) Elm's HTML is a typed value —
div [ attributes ] [ children ] — that the runtime turns into DOM. On the Ruby side, ERB is a text template with <%= %> holes producing an HTML string (the Rails server model), while Phlex builds the same markup with pure-Ruby method calls — div { h2 { … } } — which reads much like Elm's view and takes its attributes as an ordinary Ruby Hash (style: { padding: "16px" }). Both run in Ruby and are shown live in the pane.Rendering a list from data
Turning a collection into markup is a
List.map in Elm; on the Ruby side it is an ERB loop or a Phlex block — switch between them in the upper-right. All render live below.module Main exposing (main)
import Html exposing (Html, li, ul, text)
import Html.Attributes exposing (style)
fruits : List String
fruits =
[ "Apple", "Banana", "Cherry" ]
main : Html msg
main =
ul [ style "font-family" "system-ui, sans-serif" ]
(List.map (\fruit -> li [] [ text fruit ]) fruits) require "erb"
fruits = ["Apple", "Banana", "Cherry"]
template = <<~ERB
<ul style="font-family:system-ui,sans-serif">
<% fruits.each do |fruit| %>
<li><%= fruit %></li>
<% end %>
</ul>
ERB
print ERB.new(template).result(binding) ERB embeds real Ruby:
<% fruits.each %> loops and <%= fruit %> interpolates. Phlex writes the same loop as ordinary Ruby inside the view — fruits.each { |fruit| li { fruit } }. Elm has no template language at all: iteration is just List.map producing a list of li elements.Data-driven conditional styling
The view is computed from the data: each task renders green with a
✓ when done, gray with a ○ when not. Both columns render the same task list live below — pick ERB or Phlex on the Ruby side.module Main exposing (main)
import Html exposing (Html, div, text)
import Html.Attributes exposing (style)
type alias Task =
{ name : String, done : Bool }
tasks : List Task
tasks =
[ { name = "Write some Elm", done = True }
, { name = "Render it live", done = True }
, { name = "Ship the cheatsheet", done = False }
]
viewTask : Task -> Html msg
viewTask task =
div [ style "padding" "4px 0", style "color" (if task.done then "#2e9e4f" else "#999") ]
[ text ((if task.done then "✓ " else "○ ") ++ task.name) ]
main : Html msg
main =
div [ style "font-family" "system-ui, sans-serif", style "font-size" "16px" ]
(List.map viewTask tasks) require "erb"
Task = Data.define(:name, :done)
tasks = [
Task.new(name: "Write some Ruby", done: true),
Task.new(name: "Render it live", done: true),
Task.new(name: "Ship the cheatsheet", done: false),
]
template = <<~ERB
<div style="font-family:system-ui,sans-serif;font-size:16px">
<% tasks.each do |task| %>
<div style="padding:4px 0;color:<%= task.done ? "#2e9e4f" : "#999" %>">
<%= task.done ? "✓ " : "○ " %><%= task.name %>
</div>
<% end %>
</div>
ERB
print ERB.new(template).result(binding) The view reacts to the data: each task's color and marker are computed from its
done field. Elm folds the decision right into the attribute value because its if is an expression; ERB drops a Ruby ternary into a <%= %> hole; Phlex writes the ternary inline in ordinary Ruby. The Data.define record stands in for Elm's type alias Task.Composing components
A view built from smaller reusable pieces: a
badge helper renders one pill, and the main view calls it several times. Both columns compose the same way — switch ERB and Phlex on the Ruby side.module Main exposing (main)
import Html exposing (Html, div, span, text)
import Html.Attributes exposing (style)
badge : String -> Html msg
badge label =
span
[ style "background" "#eee"
, style "color" "#333"
, style "padding" "2px 8px"
, style "border-radius" "8px"
, style "margin" "0 2px"
]
[ text label ]
main : Html msg
main =
div [ style "font-family" "system-ui, sans-serif" ]
[ text "Tags: ", badge "elm", badge "ruby" ] require "erb"
def badge(label)
%(<span style="background:#eee;color:#333;padding:2px 8px;border-radius:8px;margin:0 2px">#{label}</span>)
end
template = <<~ERB
<div style="font-family:system-ui,sans-serif">
Tags: <%= badge("elm") %><%= badge("ruby") %>
</div>
ERB
print ERB.new(template).result(binding) Reuse is just a function in Elm —
badge is a function returning Html, called wherever a badge is needed. Phlex matches this most closely: a Badge component is nested with render, and plain emits bare text. ERB has no component model, so reuse falls to a helper method that returns an HTML string, which the outer template interpolates raw.Escaping untrusted text
The text here contains a
<script> tag. A safe renderer shows it as literal characters rather than running it. Watch which Ruby approach escapes automatically and which needs a nudge.module Main exposing (main)
import Html exposing (Html, div, p, text)
import Html.Attributes exposing (style)
userInput : String
userInput =
"<script>alert('xss')</script>"
main : Html msg
main =
div [ style "font-family" "system-ui, sans-serif" ]
[ p [] [ text ("Comment: " ++ userInput) ] ] require "erb"
user_input = "<script>alert('xss')</script>"
template = <<~ERB
<div style="font-family:system-ui,sans-serif">
<p>Comment: <%= ERB::Util.html_escape(user_input) %></p>
</div>
ERB
print ERB.new(template).result(binding) Elm's
text always escapes its content, so injected markup can only ever render as inert characters — cross-site scripting is impossible by construction. Phlex takes the same safe-by-default stance: block text is escaped automatically. Plain ERB is the outlier — <%= %> emits raw HTML, so you must call ERB::Util.html_escape yourself (Rails' ERB flips this to auto-escape, which is why the framework, not the language, is what makes Rails views safe).