PONY λ M2 Modula-2

Ruby.CodeCompared.To/Elm

An interactive executable cheatsheet for Rubyists learning Elm

Ruby 4.0 Elm 0.19.1
Basics & Output
Hello, World
Ruby is statement-oriented: puts performs a side effect — writing to standard output — on the spot.
puts "Hello, World!"
module Main exposing (main) import Html exposing (text) main = text "Hello, World!"
Elm has no puts and, in fact, no statements at all. You define main as an expression, and the runtime renders whatever it evaluates to — here the value text "Hello, World!", which this column scrapes as text. Where Ruby performs output as an action, Elm describes a value and lets the runtime do the effect, the first sign of its rule that ordinary functions never have hidden side effects.
Comments
Ruby writes a line comment with #, and a block comment as the =begin/=end pair, which must start in the first column.
# TODO: revisit this rate limit =begin Kept deliberately low while we load-test. =end puts "rate limit checked"
module Main exposing (main) import Html exposing (text) main = -- TODO: revisit this rate limit {- Kept deliberately low while we load-test. -} text "rate limit checked"
Elm's line comment is -- and its block comment is {- ... -}, which — unlike Ruby's =begin/=end — can sit anywhere on a line and can nest, so you can comment out a block that already contains a block comment. Elm also has a documentation-comment form, {-| ... -}, that its tooling understands, where Ruby layers the separate RDoc/YARD conventions on top of ordinary # comments.
Local bindings
In Ruby a local variable springs into being on assignment and stays reassignable for the rest of the scope.
price = 1200 quantity = 3 puts "Subtotal: #{price * quantity}"
module Main exposing (main) import Html exposing (text) main = let price = 1200 quantity = 3 in text ("Subtotal: " ++ String.fromInt (price * quantity))
Elm names intermediate values in a let ... in block, and every binding is immutable — a name is bound once and never reassigned. There is no free-floating assignment the way Ruby has: each let binding exists to feed the single expression after in. The discipline Ruby leaves to convention (don't reassign unless you mean to) is simply the only option Elm gives you.
Types & Immutability
Type annotations
Ruby has no inline type annotations and no compile-time checking; a method takes whatever it is given. This also shows Ruby's "endless" one-line method.
def initials(first, last) = "#{first[0]}#{last[0]}".upcase puts initials("ada", "lovelace")
module Main exposing (main) import Html exposing (text) initials : String -> String -> String initials first last = String.toUpper (String.left 1 first ++ String.left 1 last) main = text (initials "ada" "lovelace")
The line initials : String -> String -> String above the function is its type signature — "takes two strings, returns a string". Elm checks every such signature before the program runs, so passing a number here is a compile error, not a runtime surprise. Ruby can add comparable checking externally with .rbs files plus a tool like Steep, but nothing in the language requires it; in Elm the type is part of the code and the compiler is never optional.
Inference vs dynamic typing
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.
lengths = ["cat", "hippopotamus", "ox"].map(&:length) p lengths
module Main exposing (main) import Html exposing (text) main = let lengths = List.map String.length [ "cat", "hippopotamus", "ox" ] in text (Debug.toString lengths)
With no annotation written, Elm still works out the exact type of every value here — a list of strings in, a list of ints out — and checks it before the program runs, giving full type safety with almost none of the ceremony of writing types out. That is the Ruby trade reversed: Ruby gives you objects with behavior everywhere (42.even?, nil.to_s) at the cost of catching type errors only at runtime, while Elm gives up that runtime flexibility to catch every type error up front. Debug.toString fills the role of Ruby's p.
No mutation
Ruby data is mutable: << pushes onto the same array in place, so every reference to that cart sees the change.
cart = ["book"] cart << "pen" p cart
module Main exposing (main) import Html exposing (text) main = let cart = [ "book" ] updated = cart ++ [ "pen" ] in text (Debug.toString updated)
Elm has no mutation anywhere: cart can never change, so "adding" an item means building a brand-new list with ++ and giving it its own name. This removes an entire category of Ruby bug — the aliasing surprise where two variables point at the same array and a push through one is silently seen through the other. In Elm, a value you hold is a value you can trust never to change under you.
No open classes
Ruby classes are open: you can reopen Integer anywhere and add a method — the trick behind Rails' 5.minutes — and every integer in the program gains it.
class Integer def minutes = self * 60 end puts 5.minutes
module Main exposing (main) import Html exposing (text) minutes : Int -> Int minutes count = count * 60 main = text (String.fromInt (minutes 5))
Elm has no open classes and no monkey-patching — you cannot add a method to Int, and there are no classes to reopen. New behavior is always a plain function you define in your own module, such as minutes here, and call explicitly as minutes 5. The convenience of 5.minutes is real, but so is its cost: a value's abilities can be changed from anywhere, by any loaded gem. In Elm, where a function comes from is always visible at the call site, so nothing is ever bolted onto a type behind your back.
Numbers & Strings
Int vs Float
Ruby distinguishes Integer from Float, but picks the operation from the operands: 7 / 2 is integer division because both are integers, and you reach for fdiv to force a float.
total = 7 count = 2 puts "int: #{total / count}, float: #{total.fdiv(count)}"
module Main exposing (main) import Html exposing (text) main = let total = 7 count = 2 in text ("int: " ++ String.fromInt (total // count) ++ ", float: " ++ String.fromFloat (toFloat total / toFloat count) )
Elm keeps Int and Float as separate types with separate operators: // is integer division and / is float division, and it refuses to mix the two — dividing the ints with / would not compile until you convert them with toFloat, as shown. Where a stray .0 in Ruby silently switches you between integer and float division at runtime, Elm forces the choice into the operator and the conversion, and checks it before the program runs.
String operations
Ruby exposes string work as methods on the string object — strip, downcase — because a string is an object with behavior. In Ruby 4.0 string literals are frozen, so these return new strings.
raw = " Ada Lovelace " puts raw.strip.downcase
module Main exposing (main) import Html exposing (text) main = " Ada Lovelace " |> String.trim |> String.toLower |> text
Elm's string tools are functions in the String module, not methods on the value — String.trim, String.toLower — because an Elm string is plain immutable data, not an object that carries behavior. Chaining them with the pipe operator |> reads much like Ruby's raw.strip.downcase, but the same |> would thread the value through any function, not only ones the string happens to define. Immutability is the default here rather than the Ruby 4.0 opt-in.
Building strings
Ruby interpolates with #{...} inside a double-quoted string and silently calls to_s on whatever you embed, so the integer count needs no explicit conversion.
user = "ada" count = 3 puts "#{user} has #{count} new messages"
module Main exposing (main) import Html exposing (text) main = let user = "ada" count = 3 in text (user ++ " has " ++ String.fromInt count ++ " new messages")
Elm has no string interpolation: pieces are joined with ++, and a number must be converted with String.fromInt before it can join a string. That verbosity is deliberate — the automatic to_s coercion Ruby does for you is exactly the kind of implicit conversion Elm withholds, so you never accidentally stringify a value you did not mean to. What you gain for the extra keystrokes is that no value is ever coerced behind your back.
Splitting & joining
Ruby chains these as methods off the value — split returns an Array and join folds it back to a String.
puts "2026-07-14".split("-").join("/")
module Main exposing (main) import Html exposing (text) main = "2026-07-14" |> String.split "-" |> String.join "/" |> text
Elm spells these as ordinary functions — String.split and String.join — threaded together with the pipe operator |>, which feeds each result into the next function. Ruby's method chaining reads much the same, but it works only for methods the receiving object defines, whereas |> threads a value through any function, so the same operator composes split, join, and your own helpers uniformly.
Booleans & Conditionals
if is an expression
Ruby's if is an expression — the method returns the chosen branch with no return needed — and its else is optional.
def shipping(total) if total >= 50 "free" else "$5 flat" end end puts shipping(40)
module Main exposing (main) import Html exposing (text) shipping : Int -> String shipping total = if total >= 50 then "free" else "$5 flat" main = text (shipping 40)
Elm's if is an expression too — a rare point of agreement — but with two rules Ruby lacks: it always needs an else, and every branch must produce the same type. That is because the if yields a value the rest of the program consumes, so there can be no "missing branch" quietly evaluating to nil, which is exactly what a Ruby if with no matching branch does. The mandatory else is the compiler closing a gap Ruby leaves open.
Boolean logic & truthiness
Ruby treats every value as a boolean in a condition, and only nil and false are falsy — so 0 and "" are truthy, which is why the empty string here counts as present.
p [0, "", nil, false].map { |value| value ? "truthy" : "falsy" }
module Main exposing (main) import Html exposing (text) main = let ready = True && not False in text (Debug.toString (ready || False))
Elm has no truthiness at all: &&, ||, and not accept only the Bool type, and a condition must be an actual Bool — there is nothing else a value in that position could be. The Ruby idioms that lean on truthy/falsy values (value || default, guarding on a possibly-nil object) simply do not typecheck here; their jobs move to Maybe and explicit comparisons. In return you never puzzle over whether some value counts as true.
nil vs Maybe
Absence of a value
Ruby uses nil for a missing value — a bare find_by returns it when nothing matches — and value || default supplies a fallback. But nil is not a distinct type: any value can be nil, and nothing forces you to handle it.
def find_user(id) = id == 1 ? "Ada" : nil puts(find_user(1) || "guest")
module Main exposing (main) import Html exposing (text) findUser : Int -> Maybe String findUser id = if id == 1 then Just "Ada" else Nothing main = text (Maybe.withDefault "guest" (findUser 1))
Elm has no null and no nil. A value that might be missing has the type Maybe — either Just x or Nothing — and Maybe.withDefault plays the role of || default. The decisive difference is that absence is written into the type: the compiler will not let you use the name until you have handled the Nothing case, so the forgotten check that becomes a NoMethodError on nil in Ruby is a bug Elm makes impossible to write.
Transforming a maybe-value
Ruby's safe-navigation operator &. calls a method only when the receiver is non-nil, and Array#first returns a bare nil on an empty list.
tags = ["ruby", "elm"] puts(tags.first&.capitalize || "(untagged)")
module Main exposing (main) import Html exposing (text) capitalize : String -> String capitalize word = String.toUpper (String.left 1 word) ++ String.dropLeft 1 word main = [ "ruby", "elm" ] |> List.head |> Maybe.map capitalize |> Maybe.withDefault "(untagged)" |> text
Maybe.map reaches inside a Just to transform its value and leaves a Nothing untouched — the direct analog of tags.first&.capitalize, with no unwrapping and no nil check. The gap is that List.head returns a Maybe String, so the emptiness is visible in the type and the compiler makes you deal with it, whereas Ruby's first hands back a plain nil and remembering the &. is on you. (Note there is no built-in capitalize; behavior lives in functions you write.)
Chaining that can fail
Ruby threads possible absence by hand: Integer(raw, exception: false) returns nil instead of raising, and the following && short-circuits so nothing is called on nil.
def parse_port(raw) number = Integer(raw, exception: false) number && number.between?(1, 65535) ? number : nil end p parse_port("8080")
module Main exposing (main) import Html exposing (text) parsePort : String -> Maybe Int parsePort raw = String.toInt raw |> Maybe.andThen (\number -> if number >= 1 && number <= 65535 then Just number else Nothing ) main = text (Debug.toString (parsePort "8080"))
When one step's result can itself be missing, Maybe.andThen chains the steps and stops at the first Nothing, so the pipeline composes cleanly instead of nesting && guards. Ruby's version works, but nothing links the steps for you and no type records that the result can be missing — you must remember, at every call site, that parse_port might return nil. Elm's Maybe Int return type carries that warning everywhere it goes.
Collections
Transforming a list
Ruby chains methods on the collection itself — select filters, map transforms — and each returns a new array, so the pipeline never mutates the original. The block { |price| ... } is Ruby's inline anonymous function.
on_sale = [19, 42, 7, 88] .select { |price| price > 10 } .map { |price| price - 5 } p on_sale
module Main exposing (main) import Html exposing (text) main = [ 19, 42, 7, 88 ] |> List.filter (\price -> price > 10) |> List.map (\price -> price - 5) |> Debug.toString |> text
Elm expresses the same pipeline with the pipe operator |> feeding a value into each function in turn — List.filter is Ruby's select, List.map matches directly, and the lambda \price -> ... is the block. The functions live in the List module rather than on the value, and there is no method chaining, since lists are data, not objects. Like Ruby, every step returns a fresh list and the original is untouched — the difference is Elm guarantees that, where Ruby merely offers it.
Folding a list
Ruby's reduce (also spelled inject) collapses a list into one value, with the accumulator passed first into the block. For summing specifically, Ruby offers the purpose-built sum.
prices = [1200, 350, 90] total = prices.reduce(0) { |running, price| running + price } puts total # For this common case Ruby also has a dedicated method: puts prices.sum
module Main exposing (main) import Html exposing (text) main = let total = List.foldl (+) 0 [ 1200, 350, 90 ] in text (String.fromInt total)
Elm's List.foldl is the counterpart to reduce, folding from the left with an accumulator. Note (+): wrapping an operator in parentheses turns it into an ordinary function you can pass as an argument, exactly the way Ruby lets you hand reduce a symbol with reduce(:+). The idea is identical; Elm just treats operators as first-class functions by default rather than needing the :symbol shorthand to pass one.
Hash vs Dict
Ruby's Hash holds keys of any type at once, returns a bare nil for a missing key with [], and is freely mutable. Use fetch when a miss should raise or take a default.
stock = { "apple" => 12, "pear" => 0 } puts stock["apple"] # A missing key is a silent nil unless you demand it exists: puts stock.fetch("banana", "not carried")
module Main exposing (main) import Html exposing (text) import Dict main = let stock = Dict.fromList [ ( "apple", 12 ), ( "pear", 0 ) ] in text (Debug.toString (Dict.get "apple" stock))
An Elm Dict is an immutable key–value map, and — because a key may be absent — Dict.get returns a Maybe you must unwrap, rather than the silent nil that Ruby's [] hands back. Two constraints come with the safety: all keys share one type (no mixing strings and symbols in one Dict), and it cannot be mutated in place — updating produces a new Dict. The fetch-with-default habit you have in Ruby is essentially Dict.get followed by Maybe.withDefault.
Multiple return values
Ruby has no tuple type; it returns an Array and relies on parallel assignment to take it apart. An array carries neither a fixed length nor per-position types.
def split_name(full) first, *rest = full.split(" ") [first, rest.join(" ")] end first, last = split_name("Ada Lovelace") puts "#{last}, #{first}"
module Main exposing (main) import Html exposing (text) splitName : String -> ( String, String ) splitName full = case String.words full of first :: rest -> ( first, String.join " " rest ) [] -> ( "", "" ) main = let ( first, last ) = splitName "Ada Lovelace" in text (last ++ ", " ++ first)
Elm has a real tuple: a fixed-size group of values, each possibly a different type, taken apart by pattern — the idiomatic way to return two results. The destructuring ( first, last ) = ... reads almost exactly like Ruby's parallel assignment, but the type ( String, String ) guarantees the shape: always two values, always those types. A Ruby array return makes no such promise, so a caller expecting two values from a method that later returns three finds out only at runtime.
Sorting a list
Ruby's sort uses the elements' natural order — numbers compare numerically — and returns a brand-new array, leaving the original untouched. (The in-place sort! is the mutating exception.)
scores = [88, 42, 91, 7] p scores.sort p scores # unchanged
module Main exposing (main) import Html exposing (text) main = text (Debug.toString (List.sort [ 88, 42, 91, 7 ]))
List.sort orders a list by the natural ordering of its element type, so a list of numbers sorts numerically on its own, with none of JavaScript's convert-to-string surprise — the same well-behaved result as Ruby's sort, and likewise a fresh list rather than a mutation. The one thing Elm cannot offer is the in-place sort! variant: with no mutation in the language, sorting can only ever return a new list, never rearrange the one you passed in.
Objects vs Records
Defining a record
Ruby's closest match to a fixed-shape value is Data.define (Ruby 3.2+): an immutable value object with named readers, compared by value.
Product = Data.define(:name, :price) book = Product.new(name: "Elm in Action", price: 39) puts "#{book.name}: $#{book.price}"
module Main exposing (main) import Html exposing (text) type alias Product = { name : String, price : Int } main = let book : Product book = { name = "Elm in Action", price = 39 } in text (book.name ++ ": $" ++ String.fromInt book.price)
An Elm record is a value with named fields whose set and types are fixed and known to the compiler, declared as a type alias. It lines up neatly with Data.define — both immutable, both accessed by field name — but Elm adds two guarantees Ruby cannot: each field has a declared type (price can only ever be an Int), and reading a field that does not exist is a compile error rather than a NoMethodError discovered when that line finally runs.
Updating a record
A Ruby Data object is immutable, so it offers with — returning a fresh copy with some fields changed and the original intact. It only sets fields that already exist.
Product = Data.define(:name, :price) book = Product.new(name: "Elm in Action", price: 39) on_sale = book.with(price: 29) puts "#{on_sale.name} now $#{on_sale.price}"
module Main exposing (main) import Html exposing (text) main = let book = { name = "Elm in Action", price = 39 } onSale = { book | price = 29 } in text (onSale.name ++ " now $" ++ String.fromInt onSale.price)
Because nothing mutates in Elm, "updating" a record means building a new one with the { record | field = ... } syntax — the direct analog of Data#with, producing a fresh copy while the original stays put. Like with, it can only set fields that already exist, so a misspelled field name is caught rather than quietly creating a new one. The gain over Ruby is that Elm catches the typo at compile time, where with raises only when the line runs.
Custom Types
Enumerations
Ruby has no enum type; the idiomatic stand-in is a symbol — an interned, identity-compared name like :pending — matched with case/in. The set of symbols is open and unchecked.
def advance(status) case status in :pending then :shipped in :shipped then :delivered in :delivered then :delivered end end p advance(:pending)
module Main exposing (main) import Html exposing (text) type Status = Pending | Shipped | Delivered advance : Status -> Status advance status = case status of Pending -> Shipped Shipped -> Delivered Delivered -> Delivered main = text (Debug.toString (advance Pending))
A type in Elm lists a fixed set of named alternatives — a real enum for this order-status machine — and the compiler checks that every case handles all of them. That is the guarantee Ruby symbols cannot give: because the set of symbols is open, forgetting :delivered is not an error but a NoMatchingPatternError raised at runtime, so exhaustiveness is a hope. In Elm, adding a fourth status makes the compiler flag every case that has not yet handled it — the feature that lets Elm programs change safely.
Variants that carry data
Ruby models a "one of several shapes, each with its own data" type as one Data class per variant, matched with case/in deconstruction. The variants are unrelated classes with no shared type.
Cash = Data.define(:amount) Card = Data.define(:amount, :last4) def describe(payment) case payment in Cash(amount:) then "cash: $#{amount}" in Card(amount:, last4:) then "card ****#{last4}: $#{amount}" end end puts describe(Card.new(amount: 50, last4: "4242"))
module Main exposing (main) import Html exposing (text) type Payment = Cash Int | Card Int String describe : Payment -> String describe payment = case payment of Cash amount -> "cash: $" ++ String.fromInt amount Card amount last4 -> "card ****" ++ last4 ++ ": $" ++ String.fromInt amount main = text (describe (Card 50 "4242"))
Elm builds this into one type: each alternative of a custom type can carry its own data, so Payment is either a Cash or a Card, and a single case both selects the variant and destructures its data. The Ruby version reads similarly, but its Cash and Card are separate classes with no common parent — nothing stops a third payment kind from slipping past the case unhandled. Elm's one Payment type makes the set closed and the match exhaustive, checked before the program runs.
Pattern Matching
Matching on a list
Ruby 3's case/in brings structural matching: array patterns like [only] and [first, *rest] bind as they match. An unmatched value raises at runtime.
def rsvp(names) case names in [] then "nobody yet" in [only] then "just #{only}" in [first, *rest] then "#{first} and #{rest.length} others" end end puts rsvp(["Ada", "Bo", "Cy"])
module Main exposing (main) import Html exposing (text) rsvp : List String -> String rsvp names = case names of [] -> "nobody yet" [ only ] -> "just " ++ only first :: rest -> first ++ " and " ++ String.fromInt (List.length rest) ++ " others" main = text (rsvp [ "Ada", "Bo", "Cy" ])
Elm's case picks a branch by matching a value's shape, and its patterns line up almost exactly with Ruby's — [ only ] mirrors [only], and the head-and-tail first :: rest mirrors [first, *rest]. The difference is exhaustiveness: the Elm compiler checks that the empty, single, and many cases together cover every possible list and refuses to build if one is missing, whereas Ruby happily compiles a case with a hole and raises a NoMatchingPatternError only when a stray value reaches it.
Matching an optional value
Ruby matches nil as a literal pattern and binds anything else with a variable pattern like name. It is matching a value that happens to be a string against one that happens to be nil.
def greet(nickname) case nickname in nil then "Hello there" in name then "Hey #{name}!" end end puts greet("Ace")
module Main exposing (main) import Html exposing (text) greet : Maybe String -> String greet nickname = case nickname of Just name -> "Hey " ++ name ++ "!" Nothing -> "Hello there" main = text (greet (Just "Ace"))
Because Maybe is an ordinary custom type, one case handles its Just and Nothing branches directly. The contrast with the Ruby version is subtle but important: Just "Ace" and Nothing are two constructors of a single Maybe type the compiler tracks, whereas Ruby is matching a plain string against a plain nil with nothing tying them into one type — so nothing in Ruby guarantees the value even could be only those two things, or that you handled both.
Matching with conditions
Ruby matches an exact value and adds a trailing if guard on a variable pattern, with a plain else for the fall-through — here classifying an HTTP status code.
def category(code) case code in 200 then "OK" in n if n >= 500 then "server error" in n if n >= 400 then "client error" else "other" end end puts category(404)
module Main exposing (main) import Html exposing (text) category : Int -> String category code = case code of 200 -> "OK" other -> if other >= 500 then "server error" else if other >= 400 then "client error" else "other" main = text (category 404)
Elm mixes the exact pattern 200 with the binding pattern other that matches everything else. Elm's case has no guard clause, so the range tests Ruby writes as trailing if conditions move into an if inside the branch — a small readability trade for the guarantee that every value is covered by a pattern. Where Ruby leans on a catch-all else, Elm prefers a binding pattern like other that provably matches everything the earlier patterns did not.
Methods & Blocks
Defining functions
Ruby's "endless" method (def name(...) = expression, added in 3.0) gives a single-expression body; a conventional def ... end also returns its last expression with no return.
def full_name(first, last) = "#{first} #{last}" puts full_name("Ada", "Lovelace")
module Main exposing (main) import Html exposing (text) fullName : String -> String -> String fullName first last = first ++ " " ++ last main = text (fullName "Ada" "Lovelace")
An Elm function uses no def keyword, no parentheses around its parameters, and no return: the body is a single expression, much like Ruby's endless method. The visible differences are at the edges — Elm separates a function from its arguments with spaces (fullName "Ada" "Lovelace") rather than parentheses and commas, and carries the String -> String -> String signature above it. The chain of arrows in that signature is not decoration; it is what makes the currying two examples down possible.
Anonymous functions
Ruby writes a lambda as ->(bill) { ... }, a first-class object you store and pass around. Passing it where a block is expected takes an explicit &.
with_tip = ->(bill) { bill + (bill * 0.2).round } p [30, 50].map(&with_tip)
module Main exposing (main) import Html exposing (text) main = let withTip = \bill -> bill + round (toFloat bill * 0.2) in text (Debug.toString (List.map withTip [ 30, 50 ]))
An Elm anonymous function is introduced by a backslash before its parameter — \bill -> ... — and is just a value, passed to List.map like any other. There is no & and no distinction to bridge, because Elm has no separate notion of a "block": a function is a function wherever it appears. That single uniform idea is why Ruby's split between an ordinary argument and the one special block a method may receive simply does not exist here.
Higher-order functions & blocks
Ruby gives every method one implicit callback — the block — invoked with yield and written in { } after the call. It is the idiom behind each, map, and select.
def keep_if(numbers) numbers.select { |number| yield(number) } end p keep_if([1, 2, 3, 4]) { |number| number.even? }
module Main exposing (main) import Html exposing (text) keepIf : (Int -> Bool) -> List Int -> List Int keepIf predicate numbers = List.filter predicate numbers main = text (Debug.toString (keepIf (\number -> modBy 2 number == 0) [ 1, 2, 3, 4 ]))
In Elm you pass one function to another as an ordinary value — keepIf names its function argument predicate and hands it straight to List.filter, so there is no yield and no special block slot. The signature spells the contract out: (Int -> Bool) -> List Int -> List Int says the first argument is itself a function from Int to Bool. What Ruby makes ergonomic with a dedicated block, Elm makes uniform by having only one kind of callable.
Currying & partial application
Ruby methods are not curried — calling multiply(2) would be an argument-count error — so partial application is opt-in via Proc#curry, and multiply.curry[2] then reads like a partially applied function.
multiply = ->(factor, number) { factor * number } double = multiply.curry[2] p [1, 2, 3].map(&double)
module Main exposing (main) import Html exposing (text) multiply : Int -> Int -> Int multiply factor number = factor * number main = let double = multiply 2 in text (Debug.toString (List.map double [ 1, 2, 3 ]))
Every Elm function takes its arguments one at a time, so applying multiply to a single value yields a new, more specific function — multiply 2 is "double", no ceremony required. Partial application is therefore free and pervasive where in Ruby it is a deliberate tool you reach for with curry. This is what those chained arrows in the signature meant all along: Int -> Int -> Int is really Int -> (Int -> Int), a function that returns a function.
Function composition
Ruby composes callables with Proc#>> (left-to-right) and << (right-to-left). Composition works on Proc/lambda objects, so a plain method must first be wrapped with method(:name).
trim = ->(text) { text.strip } lower = ->(text) { text.downcase } normalize = trim >> lower puts normalize.call(" HELLO ")
module Main exposing (main) import Html exposing (text) main = let normalize = String.trim >> String.toLower in text (normalize " HELLO ")
Elm has the very same >> operator, joining two functions into one that runs the first and passes its result to the second (and << composes the other way) — String.trim >> String.toLower trims then lower-cases. Ruby borrowed this operator, and it works the same, with one asymmetry: in Elm every named function is already a plain value, so there is no method(:name) wrapping step — String.trim composes directly.
Recursion & loops
Ruby supports recursion, but also offers real mutable loops (while, times) and mutation, so idiomatic Ruby often reaches for iteration or reduce instead.
def countdown(number) number <= 0 ? "liftoff" : "#{number} " + countdown(number - 1) end puts countdown(3)
module Main exposing (main) import Html exposing (text) countdown : Int -> String countdown number = if number <= 0 then "liftoff" else String.fromInt number ++ " " ++ countdown (number - 1) main = text (countdown 3)
Elm has no for or while loop and no mutation, so a repeated computation is written as a function that calls itself — recursion is not a stylistic preference here but the mechanism. For most real work you reach for List.map and List.foldl instead of hand-written recursion, just as you would prefer reduce over a manual loop in Ruby. Elm guarantees tail-call optimization for functions written in tail position, so a properly shaped recursive loop will not overflow the stack the way a deep Ruby recursion can.
Error Handling
Exceptions vs Result
Ruby signals failure by raise-ing 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 method's signature.
def withdraw(balance, amount) raise ArgumentError, "insufficient funds" if amount > balance balance - amount end begin puts withdraw(100, 30) rescue ArgumentError => error puts error.message end
module Main exposing (main) import Html exposing (text) withdraw : Int -> Int -> Result String Int withdraw balance amount = if amount > balance then Err "insufficient funds" else Ok (balance - amount) main = text (Debug.toString (withdraw 100 30))
Elm has no exceptions at all. A computation that can fail returns a ResultOk value on success, Err reason on failure — so the failure is an ordinary value the type records and the caller is forced to handle. That closes the gap Ruby leaves open: a Ruby caller can simply forget to rescue, and the exception escapes to the top level, whereas an Elm caller cannot get at the balance without first dealing with the Err. Failure moves from invisible control flow into the function's signature, in plain sight.
Chaining fallible steps
Ruby often reaches for an inline rescue to supply a fallback when an expression raises — compact, but it recovers by catching a stack unwind and will swallow any StandardError, not just the one you meant.
quantity = begin Integer("5") * 3 rescue ArgumentError "not a number" end p quantity
module Main exposing (main) import Html exposing (text) main = let quantity = String.toInt "5" |> Result.fromMaybe "not a number" |> Result.map (\number -> number * 3) in text (Debug.toString quantity)
Result.map transforms a success value and carries any earlier Err straight through untouched, so a whole pipeline runs with no nested checks and stops at the first failure. Compared with Ruby's rescue, the difference is precision: Elm threads a specific Err "not a number" value through the computation rather than catching whatever exception happened to fly by, so it can never accidentally rescue an unrelated error. The failure is handled by data flow, not by unwinding the stack.
Rendering HTML
Rendering a profile card
Both columns build the same profile card and render it live in the pane below. On the Ruby side this uses ERB, the classic Rails template, producing an HTML string; Elm builds the same markup as a typed value with view functions.
require "erb" name = "Ada Lovelace" role = "Founder" template = <<~ERB <div style="padding:16px;border-radius:10px;background:#cc342d;color:white;font-family:system-ui,sans-serif"> <h2 style="margin:0 0 4px"><%= name %></h2> <p style="margin:0;opacity:0.85"><%= role %></p> </div> ERB print ERB.new(template).result(binding)
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 4px" ] [ text "Ada Lovelace" ] , p [ style "margin" "0", style "opacity" "0.85" ] [ text "Founder" ] ]
Elm's HTML is a typed value, not a template: div [ attributes ] [ children ] is an ordinary function call the runtime turns into real DOM. Where ERB is a text template with <%= %> holes — the familiar Rails server model — Elm has no template language at all, so the same building blocks (elements, attributes, children) are just nested function calls. Attributes are a list and children are a list, which is why the whole view reads as data you assemble rather than markup you fill in.
Rendering a list from data
Turning a collection into markup is an ERB loop on the Ruby side and a List.map on the Elm side. Both render a list of recent posts live below.
require "erb" posts = ["Shipping Elm", "Why no nil", "The update loop"] template = <<~ERB <ul style="font-family:system-ui,sans-serif"> <% posts.each do |post| %> <li><%= post %></li> <% end %> </ul> ERB print ERB.new(template).result(binding)
module Main exposing (main) import Html exposing (Html, li, ul, text) import Html.Attributes exposing (style) posts : List String posts = [ "Shipping Elm", "Why no nil", "The update loop" ] main : Html msg main = ul [ style "font-family" "system-ui, sans-serif" ] (List.map (\post -> li [] [ text post ]) posts)
ERB embeds real Ruby — <% posts.each %> loops and <%= post %> interpolates each value into the surrounding text. Elm needs no template directives because iteration is just List.map producing a list of li elements, which becomes the children list of the ul. Building markup and building a list are the same operation in Elm, so the ordinary list functions you already met are also the whole templating story.
Data-driven conditional styling
The view is computed from the data: each feature renders green with when enabled, gray with when not. Both columns render the same feature list live below. A Data.define record stands in for a plain hash on the Ruby side.
require "erb" Feature = Data.define(:name, :enabled) features = [ Feature.new(name: "Dark mode", enabled: true), Feature.new(name: "Offline sync", enabled: true), Feature.new(name: "Beta search", enabled: false), ] template = <<~ERB <div style="font-family:system-ui,sans-serif;font-size:16px"> <% features.each do |feature| %> <div style="padding:4px 0;color:<%= feature.enabled ? "#2e9e4f" : "#999" %>"> <%= feature.enabled ? "✓ " : "○ " %><%= feature.name %> </div> <% end %> </div> ERB print ERB.new(template).result(binding)
module Main exposing (main) import Html exposing (Html, div, text) import Html.Attributes exposing (style) type alias Feature = { name : String, enabled : Bool } features : List Feature features = [ { name = "Dark mode", enabled = True } , { name = "Offline sync", enabled = True } , { name = "Beta search", enabled = False } ] viewFeature : Feature -> Html msg viewFeature feature = div [ style "padding" "4px 0", style "color" (if feature.enabled then "#2e9e4f" else "#999") ] [ text ((if feature.enabled then "✓ " else "○ ") ++ feature.name) ] main : Html msg main = div [ style "font-family" "system-ui, sans-serif", style "font-size" "16px" ] (List.map viewFeature features)
The view reacts to the data: each feature's color and marker are computed from its enabled field. ERB drops a Ruby ternary into a <%= %> hole; Elm folds the same decision directly into the attribute value, since its if is an expression that produces a value. The type alias Feature plays the exact role of the Data.define record, but Elm also knows at compile time that every feature has an enabled boolean, so a view that read a missing field would not build.
Composing components
A view built from smaller reusable pieces: a navLink helper renders one link, and the main view calls it several times. On the Ruby side this is an ERB helper method returning an HTML string; in Elm it is a function returning Html.
require "erb" def nav_link(label) %(<a href="#" style="color:#5a3fc0;margin:0 8px;text-decoration:none">#{label}</a>) end template = <<~ERB <nav style="font-family:system-ui,sans-serif"> <%= nav_link("Home") %><%= nav_link("Docs") %><%= nav_link("Blog") %> </nav> ERB print ERB.new(template).result(binding)
module Main exposing (main) import Html exposing (Html, a, nav, text) import Html.Attributes exposing (href, style) navLink : String -> Html msg navLink label = a [ href "#" , style "color" "#5a3fc0" , style "margin" "0 8px" , style "text-decoration" "none" ] [ text label ] main : Html msg main = nav [ style "font-family" "system-ui, sans-serif" ] [ navLink "Home", navLink "Docs", navLink "Blog" ]
Reuse in Elm is just a function: navLink takes a label and returns Html, called wherever a link is needed — the same instinct as an ERB helper that returns a markup string, but returning a typed value rather than text. Because a component is an ordinary function, it composes with all the tools you have seen: you could List.map navLink over a list of labels, pass it around, or nest it inside another view, with no separate partial or template-lookup mechanism.
Escaping untrusted text
A user's comment contains a <script> tag. A safe renderer shows it as literal characters rather than running it. Plain ERB emits raw HTML, so it needs an explicit ERB::Util.html_escape.
require "erb" comment = "Nice post! <script>steal()</script>" template = <<~ERB <blockquote style="font-family:system-ui,sans-serif"> <%= ERB::Util.html_escape(comment) %> </blockquote> ERB print ERB.new(template).result(binding)
module Main exposing (main) import Html exposing (Html, blockquote, text) import Html.Attributes exposing (style) comment : String comment = "Nice post! <script>steal()</script>" main : Html msg main = blockquote [ style "font-family" "system-ui, sans-serif" ] [ text comment ]
Elm's text function always escapes its content, so injected markup can only ever render as inert characters — cross-site scripting is impossible by construction, with nothing to remember. Plain ERB is the opposite default: <%= %> emits raw HTML, so you must call ERB::Util.html_escape yourself, and it is Rails (not the language) that flips ERB to auto-escape. Where Rails makes safety a framework convention layered on top, Elm bakes it into the one function you use to put text on the page.
The Elm Architecture
State & update
A Rubyist reaches for a mutable object: a vote tally that changes its own @score in place as events arrive. Here the same idea is written immutably — a reducer folding messages over a value — to line up with Elm.
Model = Data.define(:score) def update(model, message) case message in :upvote then model.with(score: model.score + 1) in :downvote then model.with(score: model.score - 1) end end final = [:upvote, :upvote, :downvote].reduce(Model.new(score: 0)) do |model, message| update(model, message) end puts "Score: #{final.score}"
module Main exposing (main) import Html exposing (text) type alias Model = { score : Int } type Msg = Upvote | Downvote update : Msg -> Model -> Model update msg model = case msg of Upvote -> { model | score = model.score + 1 } Downvote -> { model | score = model.score - 1 } main = let finalModel = List.foldl update { score = 0 } [ Upvote, Upvote, Downvote ] in text ("Score: " ++ String.fromInt finalModel.score)
This is the heart of The Elm Architecture: a Model holding all state, a Msg type listing everything that can happen, and an update : Msg -> Model -> Model that returns a new model for each message. Because nothing mutates, applying a stream of messages is exactly a List.foldl — the same fold you met with lists, now folding over events. A Rubyist knows this shape as a reducer; the difference is that in Elm it is not one pattern among many but the way every application is structured, and the exhaustive case guarantees no message is left unhandled.
Wiring a live app
A Rubyist's closest analog to an interactive UI is a server round-trip: a Sinatra route renders HTML, and a form post mutates state and re-renders. (Neither side runs here — the Elm needs a live browser event loop, the Ruby a running web server.)
require "sinatra" score = 0 get "/" do "<button>▼</button> #{score} <button>▲</button>" end post "/upvote" do score += 1 redirect "/" end
module Main exposing (main) import Browser import Html exposing (Html, button, div, text) import Html.Events exposing (onClick) type alias Model = { score : Int } type Msg = Upvote | Downvote update : Msg -> Model -> Model update msg model = case msg of Upvote -> { model | score = model.score + 1 } Downvote -> { model | score = model.score - 1 } view : Model -> Html Msg view model = div [] [ button [ onClick Downvote ] [ text "▼" ] , text (String.fromInt model.score) , button [ onClick Upvote ] [ text "▲" ] ] main : Program () Model Msg main = Browser.sandbox { init = { score = 0 }, update = update, view = view }
Browser.sandbox wires the three pieces — an initial model, the update from the previous example, and a view : Model -> Html Msg — into a self-contained live application. The runtime holds the state, hands each click to update, and re-renders the view, all on the client with no page reload. That is the deep contrast with the Ruby sketch: the Rails/Sinatra model round-trips to the server and mutates a variable to change state, while Elm keeps the whole loop in the browser and never mutates — each click produces a new model. Redux and much of modern front-end state management are this architecture, borrowed from Elm. Both sides are display-only here.