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
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))
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
Model → view → Msg → update → 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.