PONY λ M2 Modula-2

Elm.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing Elm and JavaScript

Elm 0.19.1 JavaScript (ES2025)
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!"
console.log("Hello, World!");
Here Html.text turns a string into a text node, which the Elm column renders; JavaScript instead produces output as a side effect, writing straight to the console. The blank lines around main are the work of elm-format, Elm's official formatter — it is opinionated and non-configurable, so every Elm file is laid out exactly this way (two blank lines between top-level definitions, and so on).
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 /* a multi-line comment */ console.log("commented");
Unlike JavaScript's /* */, Elm's block comments {- -} genuinely nest, and documentation comments use {-| ... -}.
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 ++ "!")
const greeting = "Hello"; const name = "JavaScript"; console.log(`${greeting}, ${name}!`);
JavaScript's const is the closest analog, though it can still hold a mutable object — Elm has no mutation at all, and no var. Note that Elm joins strings with ++ where JavaScript reaches for template literals.
Types & Annotations
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))
// JSDoc is the closest JavaScript comes to a checked annotation /** @param {number} n @returns {number} */ function double(n) { return n * 2; } console.log(double(21));
Elm checks these signatures at compile time and expects one on every top-level function. Plain JavaScript has no static types; the nearest equivalent is a JSDoc comment (checked only by external tooling) or a move to TypeScript.
Type inference
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)
const numbers = [1, 2, 3, 4].map((n) => n * n); console.log(numbers);
JavaScript does no inference and no checking, so a type error surfaces only at runtime, if at all. Debug.toString renders any Elm value as text for display.
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)
const wholeHalves = Math.trunc(7 / 2); const exactHalves = 7 / 2; console.log(`${wholeHalves} and ${exactHalves}`);
Mixing the two is a compile error. JavaScript has a single number type, so 7 / 2 is always 3.5 and you reach for Math.trunc to get a whole number.
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")
const shout = "hello".toUpperCase(); const size = "hello".length; console.log(`${shout} has ${size} letters`);
So String.length is a function call rather than a property, and — because strings are immutable — each of these returns a brand-new string. JavaScript exposes the same operations as methods and properties on the string.
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)
const name = "JavaScript"; const year = 1995; console.log(`${name} first appeared in ${year}`);
That extra step is deliberate — the type system guarantees you never accidentally coerce a value, the way JavaScript's template literals silently call toString on anything you drop in.
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
console.log("a,b,c".split(",").join(" | "));
Being plain functions, they drop straight into a |> pipeline, whereas JavaScript's split/join are methods called on the string.
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)
function classify(n) { if (n > 0) return "positive"; if (n < 0) return "negative"; return "zero"; } console.log(classify(-5));
Both branches must also have the same type, so there is no "if with no else" and no undefined fall-through. JavaScript's if is a statement, which is why an early return reads as the natural multi-branch shape there.
Boolean logic
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))
const ready = true && !false; const either = true || false; console.log(ready && either);
You therefore cannot write if someList or if someString as you can in JavaScript, whose operators take any value and return one of their operands — flexible, but a frequent source of bugs.
Maybe vs null
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")))
function lookup(key) { return key === "answer" ? 42 : null; } const value = lookup("answer") ?? 0; console.log(value);
The compiler forces you to account for both cases. JavaScript's ?? here plays a role like Maybe.withDefault, supplying a fallback — but nothing makes you use it, so a missing value can still slip through.
Transforming a Maybe
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" ])))
function firstName(names) { return names.length > 0 ? names[0] : undefined; } const name = firstName(["ada", "grace"]); console.log(name !== undefined ? name.toUpperCase() : "(none)");
List.head returns a Maybe precisely because a list can be empty, so the empty case is impossible to forget. In JavaScript, indexing an empty array simply yields undefined, and the check is left to you.
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"))
function parsePositive(raw) { const n = Number.parseInt(raw, 10); if (Number.isNaN(n)) return undefined; return n > 0 ? n : undefined; } console.log(parsePositive("7"));
JavaScript approximates this with optional chaining (?.) plus manual guards. Note that String.toInt returns a Maybe Int, so a non-numeric string can never slip through as NaN.
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
const result = [1, 2, 3, 4, 5] .filter((n) => n % 2 === 0) .map((n) => n * n) .map(String) .join(", "); console.log(result);
It reads like JavaScript's method chaining but works with any function, not only methods defined on the value. Elm's lists are immutable, so List.map and List.filter always return new lists.
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)
const total = [10, 20, 30].reduce((sum, n) => sum + n, 0); console.log(total);
It is Elm's counterpart to JavaScript's reduce, though Elm passes the accumulator as the last argument. Passing (+) shows that operators are ordinary functions you can hand around by name.
Dict vs object/Map
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))
const ages = new Map([ ["Alice", 30], ["Bob", 25], ]); console.log(ages.get("Alice"));
Its keys must all be one fixed comparable type. A JavaScript Map accepts keys of any type and returns a silent undefined for a miss, rather than a Maybe you are made to handle.
Tuples
A tuple groups a fixed number of values, each possibly a different type, and is taken apart by pattern.
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)
function divide(numerator, denominator) { return [Math.trunc(numerator / denominator), numerator % denominator]; } const [quotient, remainder] = divide(17, 5); console.log(`${quotient} remainder ${remainder}`);
It is the idiomatic way to return two or three results at once. JavaScript reuses arrays plus destructuring for the same job, but without the fixed length or the per-position types.
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 ]))
// Default JS sort compares as STRINGS: "10" < "2" < "33" < "4" console.log([10, 2, 33, 4].toSorted((a, b) => a - b));
JavaScript's default sort converts elements to strings first — a classic surprise — so numbers need an explicit (a, b) => a - b. toSorted (ES2023) returns a new array instead of mutating in place, which Elm does by default.
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)
const alice = { name: "Alice", age: 30 }; console.log(`${alice.name} is ${alice.age}`);
Reading a field that does not exist, or storing the wrong type, is a compile error. A JavaScript object is open-ended, so a typo like alice.aeg quietly yields undefined.
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)
const alice = { name: "Alice", age: 30 }; const older = { ...alice, age: alice.age + 1 }; console.log(`${older.name} is now ${older.age}`);
It resembles JavaScript's object spread, but with a guarantee: the syntax can only set fields that already exist, so it can never add a misspelled one.
Records as arguments
Passing a record gives a function what amount to named arguments — each value is labeled by its field at the call site.
module Main exposing (main) import Html exposing (text) type alias Point = { x : Int, y : Int } manhattan : Point -> Int manhattan point = abs point.x + abs point.y main = text (String.fromInt (manhattan { x = 3, y = -4 }))
function manhattan(point) { return Math.abs(point.x) + Math.abs(point.y); } console.log(manhattan({ x: 3, y: -4 }));
The compiler checks every field is present and correctly typed. A JavaScript object parameter reads the same but carries no such guarantee — a missing field is simply undefined.
Custom Types
Enumerations
A custom type lists a fixed set of named alternatives — Elm's version of an enum.
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))
const Light = Object.freeze({ Red: "Red", Yellow: "Yellow", Green: "Green" }); function next(light) { switch (light) { case Light.Red: return Light.Green; case Light.Yellow: return Light.Red; case Light.Green: return Light.Yellow; } } console.log(next(Light.Red));
The compiler checks that every case handles all of them, so forgetting one will not compile. JavaScript has no native enum; people fake it with frozen objects or bare strings, and nothing warns you when a switch misses a case.
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.
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)))
function circle(radius) { return { kind: "circle", radius }; } function rectangle(width, height) { return { kind: "rectangle", width, height }; } function area(shape) { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "rectangle": return shape.width * shape.height; default: throw new Error("unknown shape"); } } console.log(area(rectangle(3, 4)));
A single case both selects the alternative and destructures its data. JavaScript models this with a kind-tagged object and a switch, needing a manual default because it cannot prove the cases are exhaustive.
Generic (parameterized) types
The lowercase a in type Tree a is a type variable, letting one definition serve a tree of any element type.
module Main exposing (main) import Html exposing (text) type Tree a = Leaf a | Branch (Tree a) (Tree a) depth : Tree a -> Int depth tree = case tree of Leaf _ -> 1 Branch left right -> 1 + max (depth left) (depth right) main = text (String.fromInt (depth (Branch (Leaf 1) (Branch (Leaf 2) (Leaf 3)))))
function leaf(value) { return { tag: "leaf", value }; } function branch(left, right) { return { tag: "branch", left, right }; } function depth(tree) { if (tree.tag === "leaf") return 1; return 1 + Math.max(depth(tree.left), depth(tree.right)); } console.log(depth(branch(leaf(1), branch(leaf(2), leaf(3)))));
It is the same mechanism behind Maybe a, List a, and Result x a. JavaScript is untyped, so a generic structure carries no element-type information at all; only TypeScript recovers something similar.
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 ])
function describe(numbers) { if (numbers.length === 0) return "empty"; if (numbers.length === 1) return `one item: ${numbers[0]}`; const [first, ...rest] = numbers; return `starts with ${first}, then ${rest.length} more`; } console.log(describe([1, 2, 3]));
The compiler checks the branches cover every shape. JavaScript approximates the same thing with length checks and array destructuring, but with no exhaustiveness guarantee.
Matching a Maybe
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"))
function greet(name) { if (name != null) return `Hello, ${name}`; return "Hello, stranger"; } console.log(greet("Ada"));
The compiler will not let you skip the Nothing branch. In JavaScript the matching null/undefined check is optional, which is why the absent case is the classic source of "cannot read properties of null".
Matching a tuple
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 ))
function quadrant([x, y]) { if (x === 0 && y === 0) return "origin"; if (x > 0 && y > 0) return "upper-right"; return "elsewhere"; } console.log(quadrant([3, 4]));
Here ( 0, 0 ) matches a literal pair while ( x, y ) binds any other. JavaScript's array destructuring can bind the parts but cannot match on literal values, so those comparisons move into the function body.
Functions & Currying
Defining functions
An Elm function definition uses no function keyword, no parentheses around its parameters, and no return.
module Main exposing (main) import Html exposing (text) add : Int -> Int -> Int add a b = a + b main = text (String.fromInt (add 2 3))
function add(a, b) { return a + b; } console.log(add(2, 3));
The body is a single expression — all a pure function needs. Arguments are separated by spaces at the call site too: add 2 3, not add(2, 3).
Currying & partial application
Elm functions take one argument at a time, so applying add to a single value yields a new, more specific function.
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 ]))
function add(a) { return (b) => a + b; } const addTen = add(10); console.log([1, 2, 3].map(addTen));
That makes partial application free and pervasive — add 10 is a ready-made "add ten". JavaScript can do the same only if you write the function to explicitly return another function.
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")
const shoutReversed = (s) => [...s].reverse().join("").toUpperCase(); console.log(shoutReversed("js"));
Here String.reverse >> String.toUpper reverses then upper-cases. JavaScript has no composition operator, so you nest calls or write a helper; method chaining fills a similar niche for values that carry the methods.
Recursion instead of 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))
function factorial(n) { let result = 1; for (let i = 2; i <= n; i++) result *= i; return result; } console.log(factorial(5));
When it doesn't recurse directly, Elm reaches for higher-order functions like List.foldl. JavaScript offers real loops, but their mutable counter has no Elm equivalent — every "loop variable" would have to be immutable.
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 ]))
const triple = (n) => n * 3; console.log([1, 2, 3].map(triple));
It is the direct counterpart of JavaScript's arrow function and, like every Elm function, it is pure and captures its surroundings by value.
Error Handling
Result instead of exceptions
A computation that can fail returns a ResultOk 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))
function safeDivide(numerator, denominator) { if (denominator === 0) throw new Error("cannot divide by zero"); return numerator / denominator; } try { console.log(safeDivide(10, 2)); } catch (error) { console.log(error.message); }
The failure is an ordinary value the caller has to handle. JavaScript throws and unwinds the stack instead, so a caller can forget to try/catch and the failure escapes unnoticed until runtime.
Chaining results
Result.map transforms a success value and carries any earlier Err straight through the chain untouched.
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)
function parse(raw) { const n = Number.parseInt(raw, 10); if (Number.isNaN(n)) return { ok: false, error: "not a number" }; return { ok: true, value: n * 2 }; } console.log(parse("20"));
A whole pipeline can run with no nested checks, the first failure surfacing at the end; Result.fromMaybe turns a Nothing into a described error. JavaScript has no built-in Result, so people hand-roll a tagged object or fall back on exceptions.
JSON & Decoding
Decoding JSON safely
Elm never trusts raw JSON: a Decoder states the shape you expect, and decoding hands back a Result.
module Main exposing (main) import Html exposing (text) import Json.Decode as Decode main = let result = Decode.decodeString (Decode.field "age" Decode.int) "{\"age\":30}" in text (Debug.toString result)
const result = JSON.parse('{"age":30}'); console.log(result.age);
You get Ok value only when the data actually matched, and a precise Err message otherwise. JavaScript's JSON.parse returns an untyped object, so a missing or wrong-typed field just yields undefined or crashes later.
Encoding JSON
Elm assembles JSON deliberately, tagging each value with its type through the Json.Encode module.
module Main exposing (main) import Html exposing (text) import Json.Encode as Encode main = let json = Encode.object [ ( "name", Encode.string "Ada" ) , ( "age", Encode.int 30 ) ] in text (Encode.encode 0 json)
const json = JSON.stringify({ name: "Ada", age: 30 }); console.log(json);
Encode.encode then renders it to a string, its first argument controlling the indentation. JavaScript's JSON.stringify serializes any object structurally, guessing types from the runtime values.
Rendering & The Elm Architecture
Rendering HTML
Both columns build the same card and render it live in the pane below the code. In Elm, HTML is a value returned from a pure function — div [ attributes ] [ children ]. On the JavaScript side, pick JavaScript (imperative DOM calls), React (a JSX component), or Svelte (a single-file 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" "#60b5cc" , style "color" "white" , style "font-family" "system-ui, sans-serif" ] [ h2 [ style "margin" "0 0 6px" ] [ text "Hello from Elm!" ] , p [ style "margin" "0" ] [ text "This HTML was produced by a pure function." ] ]
const card = document.createElement("div"); card.style.cssText = "padding:16px;border-radius:10px;background:#f7df1e;color:#1a1a1a;font-family:system-ui,sans-serif"; const heading = document.createElement("h2"); heading.style.margin = "0 0 6px"; heading.textContent = "Hello from JavaScript!"; const paragraph = document.createElement("p"); paragraph.style.margin = "0"; paragraph.textContent = "This HTML was produced by imperative DOM calls."; card.append(heading, paragraph); document.body.appendChild(card);
The Elm value is just data describing the DOM, which the runtime renders efficiently — you never call document.createElement or touch a node yourself. React works the same way: JSX builds a tree of element values that the runtime reconciles into the DOM, the idea React shares with Elm. Svelte gets to the same place from the opposite direction — its compiler turns the template into imperative DOM code ahead of time, so what runs looks like the hand-written JavaScript, but you never write it. Only plain JavaScript makes you mutate document yourself.
Rendering a list from data
A list of data becomes a list of DOM elements, rendered live below. Elm turns the array into <li> nodes with a single List.map; on the JavaScript side, compare a plain loop, React's map inside JSX, and Svelte's {#each} block with the selector in the upper-right.
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)
const fruits = ["Apple", "Banana", "Cherry"]; const list = document.createElement("ul"); list.style.fontFamily = "system-ui, sans-serif"; for (const fruit of fruits) { const item = document.createElement("li"); item.textContent = fruit; list.appendChild(item); } document.body.appendChild(list);
Turning data into a view is just List.map in Elm — the list of li elements is a value, with no loop and no manual appending. React maps exactly the same way inside JSX — {fruits.map(…)} — plus a key so its reconciler can track rows across re-renders. Svelte gives iteration its own template syntax, {#each fruits as fruit}, playing the role of List.map. Plain JavaScript iterates and mutates the DOM element by element.
The Elm Architecture: an interactive counter
Unlike the earlier examples, which render a fixed view, this counter holds state that changes as you use it. Its Model → update → view cycle is The Elm Architecture — the state-management pattern that Redux and much of modern front-end development borrowed from Elm. Compare all three JavaScript-side approaches with the selector, and click the buttons in each pane.
module Main exposing (main) import Browser import Html exposing (Html, button, div, span, text) import Html.Attributes exposing (style) import Html.Events exposing (onClick) main : Program () Int Msg main = Browser.sandbox { init = 0, update = update, view = view } type Msg = Increment | Decrement update : Msg -> Int -> Int update msg model = case msg of Increment -> model + 1 Decrement -> model - 1 view : Int -> Html Msg view model = div [ style "font-family" "system-ui, sans-serif", style "font-size" "22px" ] [ button [ onClick Decrement ] [ text "−" ] , span [ style "margin" "0 16px" ] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text "+" ] ]
let model = 0; const container = document.createElement("div"); container.style.cssText = "font-family:system-ui,sans-serif;font-size:22px"; const decrement = document.createElement("button"); decrement.textContent = "−"; const count = document.createElement("span"); count.style.margin = "0 16px"; const increment = document.createElement("button"); increment.textContent = "+"; function render() { count.textContent = String(model); } decrement.addEventListener("click", () => { model -= 1; render(); }); increment.addEventListener("click", () => { model += 1; render(); }); render(); container.append(decrement, count, increment); document.body.appendChild(container);
The Elm Architecture is ModelviewMsgupdate → new Model: view declares the UI as a value, onClick emits a message, and the runtime re-renders. React inherited that loop — useState gives you the model, the setter plays update, and the component re-runs as the view — but scoped per component instead of one global model. Svelte 5 collapses it further: let count = $state(0) is directly assignable, and the compiler wires the surgical DOM updates (no re-render at all). Plain JavaScript shows what every framework is hiding: a state variable, event listeners, and a hand-written render() keeping the two in sync.
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 — compare the ternary in JSX with Svelte's class: directive using the selector in the upper-right.
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)
const tasks = [ { name: "Write some JavaScript", done: true }, { name: "Render it live", done: true }, { name: "Ship the cheatsheet", done: false }, ]; const board = document.createElement("div"); board.style.cssText = "font-family:system-ui,sans-serif;font-size:16px"; for (const task of tasks) { const row = document.createElement("div"); row.style.padding = "4px 0"; row.style.color = task.done ? "#2e9e4f" : "#999"; row.textContent = (task.done ? "✓ " : "○ ") + task.name; board.appendChild(row); } document.body.appendChild(board);
The view reacts to the data: each task's color and marker are computed from its done field. Because Elm's if is an expression, it slots directly into the attribute value; React drops the same decision into JSX as a ternary. Svelte can do that too, but its class:done={task.done} directive is more idiomatic — the condition toggles a scoped CSS class instead of computing an inline style. Plain JavaScript assigns row.style.color imperatively as it builds each row.