Language Atlas (mini-courses for more languages) Module 12 – Elm and The Elm Architecture
The Elm Architecture, JSON and ports
Build Elm programs from Model, update, view, Cmd and Sub, decode JSON with errors that name the bad field, and pass data to and from JavaScript safely.
What you will learn
- Build a program from a Model, a Msg type, update and view, and say what the runtime does with each
- Explain what a Cmd and a Sub are, and why Cmd.batch gives no order
- Write JSON decoders and an encoder with elm/json, and read the path in a decoding error
- Pass data in with flags and through ports, and decode every value that crosses from JavaScript
- Prevent text from users from becoming markup on either side of a port, with Html.text and textContent
Before you start
What you’ll learn · Before you start
What you will learn
- Build a program from a Model, a Msg type, update and view, and say what the runtime does with each
- Explain what a Cmd and a Sub are, and why Cmd.batch gives no order
- Write JSON decoders and an encoder with elm/json, and read the path in a decoding error
- Pass data in with flags and through ports, and decode every value that crosses from JavaScript
- Prevent text from users from becoming markup on either side of a port, with Html.text and textContent
Before you start
On this page
A server sends "paise": "1450" where the app expects a number. In this lesson’s JavaScript run, the same order
still totalled 6750, because * turned the text into a number, while a sum of its prices came out as 12001450;
the Elm decoder stopped at the border instead and said where: json.items[1].paise, Expecting an INT.
Here you build programs the way Elm structures every one, a model, messages, update and view, and see what the
runtime does between them; you send commands and find that a batch of them keeps no order; you decode and encode
JSON with errors that name the bad field; you pass values in through flags and ports and see what happens when one
has the wrong type; and you keep text from users from turning into markup on either side of the border.
The runtime calls update and view, and your code returns values
In The Elm Architecture a program is four pieces. The model holds the whole state. A message type, Msg,
lists everything that can happen. update takes a message and the model and returns the next model. view takes the
model and returns a description of the page. Counter.elm counts cups of chai with three buttons:
module Counter exposing (Model, Msg(..), init, main, update, view)
{-| A counter of cups of chai, as a Browser.element: The Elm Architecture in
one module.
-}
import Browser
import Html exposing (Html, button, div, p, text)
import Html.Events exposing (onClick)
type alias Model =
{ cups : Int }
type Msg
= Add
| Remove
| Reset
init : () -> ( Model, Cmd Msg )
init _ =
( { cups = 0 }, Cmd.none )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Add ->
( { model | cups = model.cups + 1 }, Cmd.none )
Remove ->
( { model | cups = max 0 (model.cups - 1) }, Cmd.none )
Reset ->
( { model | cups = 0 }, Cmd.none )
view : Model -> Html Msg
view model =
div []
[ p [] [ text (cupsOfChai model.cups) ]
, button [ onClick Remove ] [ text "Remove one" ]
, button [ onClick Add ] [ text "Add one" ]
, button [ onClick Reset ] [ text "Reset" ]
]
cupsOfChai : Int -> String
cupsOfChai cups =
if cups == 1 then
"1 cup of chai"
else
String.fromInt cups ++ " cups of chai"
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
} Your code never changes the page and never stores the model: it returns values, and the runtime does the rest. Step through one click:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The first view. At start the runtime calls init, gets { cups = 0 }, calls view with it and draws the result: "0 cups of chai" and three buttons.
- A click becomes a message. The Add one button was drawn with onClick Add. A click on it hands the runtime the message Add; nothing in your code runs yet.
- update decides. The runtime calls update Add with the current model. update is a pure function: it returns the new model { cups = 1 } and Cmd.none, a request for nothing.
- A new model. The runtime keeps the new model. Your code never stores it anywhere itself, so there is one source of truth for the whole element.
- view describes the page. The runtime calls view with { cups = 1 }. view returns a description of the page as Html values, not changes to make.
- Only the difference. The runtime compares the new description with the last one and changes the page where they differ: the text becomes "1 cup of chai", and the buttons stay as they are.
- Waiting again. The loop is back where it started, waiting for the next message. Replay.elm runs the same update without a page: Add, Add, Remove, Reset, Remove gave 1, 2, 1, 0 and 0.
Because update is a plain function, a program can drive it without a browser. Replay.elm imports Counter and
feeds its update five messages, as five clicks would, and the session also builds Counter itself:
port module Replay exposing (main)
{-| Plays a list of messages through Counter.update, the way the runtime would
after clicks, and reports the model after each one.
-}
import Counter exposing (Msg(..))
port out : String -> Cmd msg
clicks : List Msg
clicks =
[ Add, Add, Remove, Reset, Remove ]
label : Msg -> String
label msg =
case msg of
Add ->
"Add"
Remove ->
"Remove"
Reset ->
"Reset"
step : Msg -> ( List String, Counter.Model ) -> ( List String, Counter.Model )
step msg ( lines, model ) =
let
( next, _ ) =
Counter.update msg model
in
( lines ++ [ String.padRight 8 ' ' (label msg) ++ "cups = " ++ String.fromInt next.cups ], next )
report : String
report =
let
( start, _ ) =
Counter.init ()
( lines, _ ) =
List.foldl step ( [ "init cups = " ++ String.fromInt start.cups ], start ) clicks
in
String.join "\n" lines
main : Program () () Never
main =
Platform.worker
{ init = \_ -> ( (), out report )
, update = \_ model -> ( model, Cmd.none )
, subscriptions = \_ -> Sub.none
} """Builds the Counter element and measures it, replays five clicks through its update function headlessly, and sends
three commands through one port with Cmd.batch (session.py plays it)."""
from session import cache, run
cache()
run("""
cd tea
elm make src/Counter.elm --optimize --output=build/counter.js
wc -c < build/counter.js
elm make src/Replay.elm --optimize --output=build/replay.js
node run.cjs Replay
elm make src/Batch.elm --optimize --output=build/batch.js
node run.cjs Batch
""") Output
$ cd tea
$ elm make src/Counter.elm --optimize --output=build/counter.js
Dependencies ready!
Success! Compiled 1 module.
Counter ───> build/counter.js
$ wc -c < build/counter.js
110515
$ elm make src/Replay.elm --optimize --output=build/replay.js
Success! Compiled 1 module.
Replay ───> build/replay.js
$ node run.cjs Replay
init cups = 0
Add cups = 1
Add cups = 2
Remove cups = 1
Reset cups = 0
Remove cups = 0
$ elm make src/Batch.elm --optimize --output=build/batch.js
Success! Compiled 1 module.
Batch ───> build/batch.js
$ node run.cjs Batch
third
second
first
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tea_session.py
The replay printed the count after each message: 1, 2, 1, 0, and 0 again, because Remove at 0 keeps the count at
0 with max 0. The optimised counter.js is 110,515 bytes, against 65,308 for the worker of the first lesson,
because it carries the virtual DOM and the browser runtime. Counter is a Browser.element, one of five kinds of program:
| Program | What it owns | What it may use |
|---|---|---|
Browser.sandbox |
one node of a page | init, update, view; no commands, subscriptions, flags or ports |
Browser.element |
one node of a page | also Cmd, Sub, flags and ports |
Browser.document |
the page’s <title> and <body> |
as element |
Browser.application |
the whole page and its URLs | as document, plus onUrlRequest and onUrlChange |
Platform.worker |
no page at all | init, update, subscriptions, flags and ports |
Test update, not the page
Most of a program’s behaviour is in update, and update is pure, so tests can call it with a model and a message
and compare what comes back, as Replay does. The project lesson of this module tests its core that way with
elm-test.
Checkpoint
Check: The parts of The Elm Architecture
Cmd and Sub are requests that the runtime carries out
update returns a pair: the next model and a command (Cmd): A value of type Cmd Msg that asks the Elm runtime to do something outside the program, such as send an HTTP request or a value through a port. update returns it; the runtime carries it out and may answer with a message., a value of type Cmd Msg that asks
the runtime to do something, such as send a request, read the time or send a value out through a port. Cmd.none
asks for nothing. A subscription (Sub): A value of type Sub Msg that names outside events a program wants to hear about, such as clock ticks or values sent to a port. The runtime turns each event into a message for update., Sub Msg, is the other direction: subscriptions says
which outside events the program wants to hear about, and the runtime turns each one into a message for update.
Neither is an action you take: both are descriptions that the runtime carries out, which is why the same update
runs in a browser, under Node.js and in a test.
The last program of the session, Batch.elm, hands the runtime three commands for one port in one Cmd.batch:
out "first", out "second" and out "third". The subscriber received them as third, second, first.
port module Batch exposing (main)
{-| Three commands for one port, handed to the runtime in one Cmd.batch.
-}
port out : String -> Cmd msg
main : Program () () Never
main =
Platform.worker
{ init = \_ -> ( (), Cmd.batch [ out "first", out "second", out "third" ] )
, update = \_ model -> ( model, Cmd.none )
, subscriptions = \_ -> Sub.none
} That is not a bug. The documentation of Cmd.batch says that each command is handed to the runtime at the same
time and that there are no ordering guarantees about the results. When order matters, send one message that
carries everything in order, a list or one string, as every worker of this module does.
Relying on the order of a batch
Sending the lines of a report as Cmd.batch (List.map out lines) hands them to the runtime in no promised order,
as Batch showed with three. Join the lines into one string, or send a List String through the port, and
JavaScript gets them in order.
Checkpoint
Check: Commands and subscriptions
Decoders check JSON at the border and name the bad field
JSON from a server is untyped text. Elm turns it into typed values with a JSON decoder: An elm/json value of type Decoder a that describes the shape a JSON value must have and turns it into an Elm value of type a, or into an error that names the path of the first value that did not fit., a
value that describes the shape it expects. OrderDecoder.elm reads "version" first and then the fields of that
version, every item’s name, price in paise and quantity, and an optional note. The session first moves elm/json
from the indirect to the direct dependencies with elm install, offline, because the program imports it now:
module OrderDecoder exposing (Item, Order, decoder, encode, looseDecoder)
{-| Decoders for an order sent by a server as JSON, in two versions, and an
encoder for sending one back.
-}
import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode
type alias Item =
{ name : String
, paise : Int
, quantity : Int
}
type alias Order =
{ id : String
, items : List Item
, note : Maybe String
}
{-| Reads "version" first, then the fields of that version.
-}
decoder : Decoder Order
decoder =
Decode.field "version" Decode.int
|> Decode.andThen forVersion
forVersion : Int -> Decoder Order
forVersion version =
case version of
1 ->
Decode.map3 Order
(Decode.field "id" Decode.string)
(Decode.field "lines" (Decode.list itemV1))
(Decode.succeed Nothing)
2 ->
Decode.map3 Order
(Decode.field "id" Decode.string)
(Decode.field "items" (Decode.list item))
(optionalField "note" Decode.string)
_ ->
Decode.fail ("version " ++ String.fromInt version ++ " is not one this app reads")
itemV1 : Decoder Item
itemV1 =
Decode.map3 Item
(Decode.field "item" Decode.string)
(Decode.field "paise" Decode.int)
(Decode.field "qty" Decode.int)
item : Decoder Item
item =
Decode.map3 Item
(Decode.field "name" Decode.string)
(Decode.field "paise" Decode.int)
(Decode.field "quantity" Decode.int)
{-| A field that may be missing or null; when it is there, it must decode.
-}
optionalField : String -> Decoder a -> Decoder (Maybe a)
optionalField name inner =
Decode.keyValuePairs Decode.value
|> Decode.andThen
(\pairs ->
if List.any (\( key, _ ) -> key == name) pairs then
Decode.field name (Decode.nullable inner)
else
Decode.succeed Nothing
)
{-| The same version 2 order, read with Decode.maybe for the note: a note of the
wrong type is quietly dropped.
-}
looseDecoder : Decoder Order
looseDecoder =
Decode.map3 Order
(Decode.field "id" Decode.string)
(Decode.field "items" (Decode.list item))
(Decode.maybe (Decode.field "note" Decode.string))
encode : Order -> Encode.Value
encode order =
Encode.object
[ ( "version", Encode.int 2 )
, ( "id", Encode.string order.id )
, ( "items"
, Encode.list
(\i -> Encode.object [ ( "name", Encode.string i.name ), ( "paise", Encode.int i.paise ), ( "quantity", Encode.int i.quantity ) ])
order.items
)
, ( "note", Maybe.map Encode.string order.note |> Maybe.withDefault Encode.null )
] DecodeDemo.elm decodes each file of samples/ and prints what came out; run.cjs reads the files and passes them
as flags, a list of name and text pairs:
{"version": 2, "id": "A-19", "items": [{"name": "chai", "paise": 1200, "quantity": 2}, {"name": "samosa", "paise": "1450", "quantity": 3}]} The same document, added up in JavaScript with no decoder in between, gave a right total by luck and a wrong sum:
1450 * 3 and "1450" * 3 are both 4350, but 1200 + "1450" turns the number into text and joins the two:
// The order of samples/order-4-text-price.json, added up in JavaScript with no decoder in between.
import { readFileSync } from 'node:fs';
const order = JSON.parse(readFileSync(new URL('./samples/order-4-text-price.json', import.meta.url), 'utf8'));
const total = order.items.reduce((sum, item) => sum + item.paise * item.quantity, 0);
const prices = order.items.reduce((sum, item) => sum + item.paise, 0);
console.log(`total of the lines: ${total}`);
console.log(`sum of the prices: ${prices}`); Output
total of the lines: 6750 sum of the prices: 12001450
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sum_in_js.mjs
Now the Elm side, with every document of samples/:
"""Moves elm/json from the indirect to the direct dependencies with elm install, offline, then builds DecodeDemo and
decodes the six documents of samples/ with it (session.py plays it). elm install asks before it changes elm.json, and
the session answers y."""
from session import cache, run
cache()
run("""
cd json
elm install elm/json
elm make src/DecodeDemo.elm --optimize --output=build/decode.js
node run.cjs
""", stdin="y\n") Output
$ cd json
$ elm install elm/json
I found it in your elm.json file, but in the "indirect" dependencies.
Should I move it into "direct" dependencies for more general use? [Y/n]: Success!
$ elm make src/DecodeDemo.elm --optimize --output=build/decode.js
Success! Compiled 2 modules.
DecodeDemo ───> build/decode.js
$ node run.cjs
order-1-v2.json
Ok A-17: 2 items, 6750 paise, note "less sugar"
order-2-v1.json
Ok A-9: 1 item, 1200 paise, no note
order-3-no-note.json
Ok A-18: 1 item, 1200 paise, no note
order-4-text-price.json
Err Problem with the value at json.items[1].paise:
"1450"
Expecting an INT
order-5-note-number.json
Err The Json.Decode.oneOf at json.note failed in the following 2 ways:
(1) Problem with the given value:
42
Expecting null
(2) Problem with the given value:
42
Expecting a STRING
order-6-version-3.json
Err Problem with the given value:
{
"version": 3,
"id": "A-21",
"items": []
}
version 3 is not one this app reads
order-5-note-number.json with Decode.maybe
Ok A-20: 0 items, 0 paise, no note
encoded again
{"version":2,"id":"A-17","items":[{"name":"chai","paise":1200,"quantity":2},{"name":"samosa","paise":1450,"quantity":3}],"note":"less sugar"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_session.py
Three documents became orders: version 2 with a note, version 1 converted to the same Order, and one with no note,
read as Nothing. The other three were refused, each with a path: json.items[1].paise for the price sent as
"1450"; json.note, where 42 failed both ways the note may be read, null or a string; and the version 3 document,
which the decoder rejected with its own message through Decode.fail. Follow the documents through the decoder:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Version first. The decoder reads one field before anything else: "version", which must be an Int. Every document of the samples has one.
- Choose the shape. Decode.andThen picks the rest of the decoder from the value: version 1 has "lines" with "item" and "qty", version 2 has "items" with "name" and "quantity".
- An unknown version. For version 3 the decoder fails on purpose, with Decode.fail and its own message, and the Err carries the whole document.
- Every item, every field. Each item needs a String name and Int paise and quantity. In order-4, the second item has the price "1450", a string, so the Err names the path json.items[1].paise and says Expecting an INT.
- The note, strictly. The note may be missing or null, both read as Nothing. When it is there it must be a String: a note of 42 gives an Err at json.note, while Decode.maybe would have turned it into Nothing without a word.
- An Order, or a reason. A document that passes every step becomes an Order, here A-17 with 2 items and 6750 paise. Nothing that failed reaches update: the error is a value you can show or log.
The last two entries of the report matter. Read with Decode.maybe, the same document with "note": 42 was accepted as an order
with no note: Decode.maybe turns a value of the wrong type into Nothing as quietly as a missing one, which hides
a broken server. optionalField accepts a missing field or null, and refuses anything else. Encoding is the
mirror image: Json.Encode builds the value, and Encode.encode 0 turned the first order back into one line of
JSON.
Key idea
Decode every JSON value at the border into the types your program uses; a decoding error is a value with the path
of the bad field, so nothing malformed reaches update, and Decode.maybe is only for fields whose type may
really vary.
Checkpoint
Check: JSON decoders and their errors
Flags and ports carry values across the border, and check their type
Elm reaches JavaScript in three ways: flags: The one value JavaScript passes to an Elm program when it starts it, as Elm.Main.init({ flags: … }). init receives it; a value of the wrong type makes init throw unless the flags are declared as Json.Decode.Value and decoded., one value given to init at start-up;
ports, for values in both directions while the program runs; and custom elements, JavaScript behind a tag that the
view places. Till.elm uses the first two. It starts with the shop’s name as a flag, hears prices through the
incoming port prices and sends the running total out through totals:
port module Till exposing (main)
{-| A till without a screen. JavaScript gives the shop's name as a flag, sends
prices in paise through the port prices, and hears the running total through
the port totals.
-}
port prices : (Int -> msg) -> Sub msg
port totals : { count : Int, paise : Int } -> Cmd msg
type alias Flags =
{ shop : String }
type alias Model =
{ shop : String
, count : Int
, paise : Int
}
type Msg
= Scanned Int
init : Flags -> ( Model, Cmd Msg )
init flags =
( { shop = flags.shop, count = 0, paise = 0 }, Cmd.none )
update : Msg -> Model -> ( Model, Cmd Msg )
update (Scanned price) model =
let
next =
{ model | count = model.count + 1, paise = model.paise + price }
in
( next, totals { count = next.count, paise = next.paise } )
main : Program Flags Model Msg
main =
Platform.worker
{ init = init
, update = update
, subscriptions = \_ -> prices Scanned
} run.cjs sends three prices, then a price as the text '1450', then starts a second till with { shop: 42 }. Both
tills are development builds, so that every error carries its full message:
// The typed till: three prices, then a price sent as text, then a start with a flag of the wrong type.
const { Elm } = require('./build/till.js');
const app = Elm.Till.init({ flags: { shop: 'Corner Cafe' } });
app.ports.totals.subscribe((t) => console.log(`${t.count} item${t.count === 1 ? '' : 's'}, ${t.paise} paise`));
for (const price of [1200, 1450, 1450]) app.ports.prices.send(price);
console.log('sent three prices');
try {
app.ports.prices.send('1450');
} catch (error) {
console.log(`send('1450') threw: ${error.message}`);
}
try {
Elm.Till.init({ flags: { shop: 42 } });
} catch (error) {
console.log(`init with { shop: 42 } threw: ${error.message}`);
} """Builds the two tills of till/ in development mode, so that errors carry their full messages, and runs each with the
same prices, one of them sent as text (session.py plays it)."""
from session import cache, run
cache()
run("""
cd till
elm make src/Till.elm --output=build/till.js
node run.cjs
elm make src/TillSafe.elm --output=build/tillsafe.js
node safe.cjs
""") Output
$ cd till
$ elm make src/Till.elm --output=build/till.js
Dependencies ready!
Success! Compiled 1 module.
Till ───> build/till.js
$ node run.cjs
Compiled in DEV mode. Follow the advice at https://elm-lang.org/0.19.3/optimize for better performance and smaller assets.
sent three prices
send('1450') threw: Trying to send an unexpected type of value through port `prices`:
[object Object]
init with { shop: 42 } threw: Problem with the flags given to your Elm program on initialization.
Problem with the value at json.shop:
42
Expecting a STRING
1 item, 1200 paise
2 items, 2650 paise
3 items, 4100 paise
$ elm make src/TillSafe.elm --output=build/tillsafe.js
Success! Compiled 1 module.
TillSafe ───> build/tillsafe.js
$ node safe.cjs
Compiled in DEV mode. Follow the advice at https://elm-lang.org/0.19.3/optimize for better performance and smaller assets.
1 item, 1200 paise
2 items, 2650 paise
rejected: Problem with the given value: "1450" Expecting an INT
3 items, 4100 paise
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ports_session.py
Three things happened. The totals came out after the line sent three prices and after both errors: send
returns at once, and the outgoing messages reach the subscriber later. The text '1450' made send throw in the
JavaScript that called it, with a message that ends in [object Object] instead of the reason. And the flags
{ shop: 42 } made init throw, this time with a clear message: Problem with the value at json.shop,
Expecting a STRING. The flags chapter of the guide calls this failing fast, and notes that many programs take their
flags as a Json.Decode.Value and decode them in Elm, to decide themselves what a bad value means. The second till
does the same for its port: TillSafe turned '1450' into
rejected: Problem with the given value: "1450" Expecting an INT and counted the next price as usual:
port module TillSafe exposing (main)
{-| The till with a port that takes any JavaScript value and decodes it in Elm,
so a value of the wrong type becomes a message instead of an exception.
-}
import Json.Decode as Decode
port prices : (Decode.Value -> msg) -> Sub msg
port totals : String -> Cmd msg
type alias Model =
{ count : Int
, paise : Int
}
type Msg
= Scanned Decode.Value
update : Msg -> Model -> ( Model, Cmd Msg )
update (Scanned value) model =
case Decode.decodeValue Decode.int value of
Ok price ->
let
next =
{ count = model.count + 1, paise = model.paise + price }
in
( next, totals (items next.count ++ ", " ++ String.fromInt next.paise ++ " paise") )
Err error ->
( model, totals ("rejected: " ++ String.join " " (String.words (Decode.errorToString error))) )
items : Int -> String
items count =
if count == 1 then
"1 item"
else
String.fromInt count ++ " items"
main : Program () Model Msg
main =
Platform.worker
{ init = \_ -> ( { count = 0, paise = 0 }, Cmd.none )
, update = update
, subscriptions = \_ -> prices Scanned
} The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Flags at the start. Elm.Till.init({ flags: { shop: 'Corner Cafe' } }) gives init one value. The runtime checks it against the Flags type before init runs.
- A price comes in. app.ports.prices.send(1200) hands the runtime a JavaScript number. prices is declared (Int -> msg) -> Sub msg, so the number must be an Int, and it is.
- update runs. The runtime wraps the value in the message Scanned 1200 and calls update, which adds it to the model and returns a totals command.
- Totals go out later. The totals for all three prices reached the subscriber only after run.cjs had printed sent three prices and both error lines: send returns before the outgoing messages are delivered.
- Text where an Int belongs. send('1450') gives a String to a port that wants an Int. The runtime refuses it by throwing in the JavaScript that called send, and the development build's message ends with [object Object].
- Flags of the wrong type. A second start with { shop: 42 } fails the same way, but with a full message: Problem with the value at json.shop, Expecting a STRING.
- Decode it in Elm. TillSafe declares its port with Json.Decode.Value, so any value gets in, and update decodes it. '1450' becomes rejected: … Expecting an INT, and the next price still counts.
Checkpoint
Check: Flags and ports
Html.text keeps text as text, and so must the JavaScript side
A name typed by a user, such as Asha <b>VIP</b>, must reach the page as those characters, not as a bold tag:
markup that comes from input is how cross-site scripting (XSS): A weakness in which text from a user or another site reaches a page as markup or script instead of as text, so the browser runs it with the page's rights. Writing such text with textContent instead of innerHTML prevents it. starts. Inside Elm the
virtual DOM guards it. elm/virtual-dom 1.0.5 draws Html.text with createTextNode, which never reads markup; it
turns a script element made with Html.node into a p; it renames attributes that start with on, and the
innerHTML and outerHTML properties, to harmless data- names; and in an optimised build it replaces a
javascript: link with an empty string. A view cannot ask for markup from a string.
The JavaScript side of a port has no such guard. A subscriber that writes a message with el.innerHTML = … hands the
browser markup to parse, and OWASP’s cross-site scripting cheat sheet names innerHTML as an unsafe sink and
textContent as the safe one: it creates a text node without parsing HTML. text_check.mjs shows the difference in
Node.js, which has no page: a weak function joins the message into HTML as innerHTML would receive it, a fixed one
escapes it as a text node keeps it, and a check counts the elements in each result:
// Two ways a port subscriber could put a message from a user on the page, and a check that tells them apart.
const message = 'Asha <b>VIP</b>';
// Weak: joins strings into HTML, as el.innerHTML = '<p>' + message + '</p>' would.
const weak = (text) => '<p>' + text + '</p>';
// Fixed: escapes the characters HTML reads as markup, so the text stays text, as el.textContent = message does.
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
const fixed = (text) => '<p>' + escapeHtml(text) + '</p>';
// The check: the only element in the result is the <p> the code itself wrote.
const elements = (html) => (html.match(/<[a-z]/gi) ?? []).length;
for (const [name, render] of [['weak', weak], ['fixed', fixed]]) {
const html = render(message);
const verdict = elements(html) === 1 ? 'PASS: one element, the message is text' : `FAIL: ${elements(html)} elements, the message added markup`;
console.log(`${name.padEnd(5)} ${html}`);
console.log(` ${verdict}`);
} Output
weak <p>Asha <b>VIP</b></p>
FAIL: 2 elements, the message added markup
fixed <p>Asha <b>VIP</b></p>
PASS: one element, the message is text
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node text_check.mjs
| Where the text goes | What happens to Asha <b>VIP</b> |
|---|---|
Html.text name in an Elm view |
a text node: the page shows the characters |
el.textContent = name in a port subscriber |
a text node: the page shows the characters |
el.innerHTML = '<p>' + name + '</p>' |
parsed as markup: a second element, the check’s FAIL |
Text in, text out, on both sides
Show values from ports with textContent, set attributes with setAttribute on a fixed name that is never an event
handler such as onclick, and never build HTML strings from them; in a browser pass the raw text to
textContent, without escaping it first. The weak line of
text_check.mjs failed its check and the fixed line passed: keep a check like it next to every subscriber that
writes to the page.
Checkpoint
Check: Keeping text from users as text
Practise: decode an order, then update a cart
A lab of two JavaScript exercises, whose sample tests the page runs for you. In the first you write the order
decoder’s rules by hand, an error path for every bad value included, which shows how much a decoder checks; its
tests are the six documents of samples/ and a few more. In the second you write a cart’s update as the runtime
would call it, one message and one model in and the next model out, against tests that freeze every model, so a
change made in place fails at once:
Code lab · 2 steps
Decode at the border, then update a model
Read an order from JSON with an error path for the first bad value, then write a cart's update that never changes the model it gets.
Exercise · Medium · JavaScript
Decode an order with a path in every error
Write decodeOrder(text) in decode.mjs: it reads an order sent as JSON text with the rules of OrderDecoder.elm and returns { ok: true, order } or { ok: false, error }, where error names the path of the first bad value the way elm/json does, such as "json.items[1].paise: expecting an Int".
"version"comes first and must be a whole number (json.version: expecting an Int). Version 1 has"lines"whose items have"item","paise"and"qty"; version 2 has"items"whose items have"name","paise"and"quantity". Any other version givesjson.version: version 3 is not one this app reads(with its number)."id"must be a string (json.id: expecting a String), and every item's name a string and its paise and quantity whole numbers, checked in that order, item by item, with the item's index in the path.- In version 2,
"note"may be missing ornull, both read asnull; if it is anything else it must be a string (json.note: expecting a String or null). Version 1 orders have no note. - Text that is not JSON gives
json: not valid JSON.
order is { id, items: [{ name, paise, quantity }], note } for both versions. The sample tests use the six documents of the lesson's samples/ folder and a few more edge cases.
Starter code · decode.mjs
/** An order from JSON text, as OrderDecoder.elm reads it: { ok: true, order } or { ok: false, error }. */
export function decodeOrder(text) {
// Replace this: it trusts the server, so a price sent as "1450" goes through as text and a missing field as undefined.
const json = JSON.parse(text);
const items = (json.items ?? json.lines ?? []).map((i) => ({ name: i.name ?? i.item, paise: i.paise, quantity: i.quantity ?? i.qty }));
return { ok: true, order: { id: json.id, items, note: json.note ?? null } };
} The sample tests · decode.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { decodeOrder } from './decode.mjs';
// The six documents of the lesson's samples/ folder.
const order1 = '{"version": 2, "id": "A-17", "items": [{"name": "chai", "paise": 1200, "quantity": 2}, {"name": "samosa", "paise": 1450, "quantity": 3}], "note": "less sugar"}';
const order2 = '{"version": 1, "id": "A-9", "lines": [{"item": "chai", "paise": 1200, "qty": 1}]}';
const order3 = '{"version": 2, "id": "A-18", "items": [{"name": "chai", "paise": 1200, "quantity": 1}]}';
const order4 = '{"version": 2, "id": "A-19", "items": [{"name": "chai", "paise": 1200, "quantity": 2}, {"name": "samosa", "paise": "1450", "quantity": 3}]}';
const order5 = '{"version": 2, "id": "A-20", "items": [], "note": 42}';
const order6 = '{"version": 3, "id": "A-21", "items": []}';
test('version 2 with a note, and version 1, become the same shape', () => {
assert.deepEqual(decodeOrder(order1), {
ok: true,
order: {
id: 'A-17',
items: [
{ name: 'chai', paise: 1200, quantity: 2 },
{ name: 'samosa', paise: 1450, quantity: 3 },
],
note: 'less sugar',
},
});
assert.deepEqual(decodeOrder(order2), { ok: true, order: { id: 'A-9', items: [{ name: 'chai', paise: 1200, quantity: 1 }], note: null } });
});
test('a missing note or a null note is null', () => {
assert.deepEqual(decodeOrder(order3), { ok: true, order: { id: 'A-18', items: [{ name: 'chai', paise: 1200, quantity: 1 }], note: null } });
assert.deepEqual(decodeOrder('{"version": 2, "id": "A-22", "items": [], "note": null}'), { ok: true, order: { id: 'A-22', items: [], note: null } });
});
test('the first bad value is named by its path', () => {
assert.deepEqual(decodeOrder(order4), { ok: false, error: 'json.items[1].paise: expecting an Int' });
assert.deepEqual(decodeOrder(order5), { ok: false, error: 'json.note: expecting a String or null' });
assert.deepEqual(decodeOrder(order6), { ok: false, error: 'json.version: version 3 is not one this app reads' });
});
test('versions, ids, fractions and fields in order', () => {
assert.deepEqual(decodeOrder('{"version": "2", "id": "A-1", "items": []}'), { ok: false, error: 'json.version: expecting an Int' });
assert.deepEqual(decodeOrder('{"version": 2, "items": []}'), { ok: false, error: 'json.id: expecting a String' });
assert.deepEqual(decodeOrder('{"version": 2, "id": "A-2", "items": [{"name": "chai", "paise": 1200.5, "quantity": 1}]}'), { ok: false, error: 'json.items[0].paise: expecting an Int' });
assert.deepEqual(decodeOrder('{"version": 1, "id": "A-3", "lines": [{"item": "chai", "paise": 1200, "qty": 1}, {"item": 7, "paise": 1, "qty": "x"}]}'), { ok: false, error: 'json.lines[1].item: expecting a String' });
assert.deepEqual(decodeOrder('{"version": 2, "id": "A-4", "items": {"name": "chai"}}'), { ok: false, error: 'json.items: expecting a List' });
});
test('text that is not JSON, and JSON that is not an object', () => {
assert.deepEqual(decodeOrder('{"version": 2,'), { ok: false, error: 'json: not valid JSON' });
assert.deepEqual(decodeOrder('null'), { ok: false, error: 'json.version: expecting an Int' });
assert.deepEqual(decodeOrder('[2]'), { ok: false, error: 'json.version: expecting an Int' });
}); A hint
Write one small checker per kind of value, each taking the value and its path, such as int(value, 'json.items[1].paise'), and throwing an error with that message when the value is wrong. decodeOrder then calls them in field order inside one try, and turns the first error into { ok: false, error }. Number.isInteger tells a whole number from 1450.5 and from the string "1450".
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · JavaScript
Write a cart's update the way Elm runs it
In The Elm Architecture, update takes a message and the current model and returns the next model; the runtime keeps it, and nothing else changes. Write the update of a small cart in cart.mjs with the same rules, in JavaScript, so that it could stand in for Counter.update with more messages:
- The model is
{ lines: [{ item, quantity }] }, the lines in the order their items were first added. { tag: 'Add', item }adds one ofitem: its line's quantity grows by 1, or a new line{ item, quantity: 1 }goes at the end.{ tag: 'Remove', item }takes one away; a line that reaches 0 disappears; removing an item that is not in the cart returns a model equal to the one given.{ tag: 'Clear' }empties the cart.- Any other tag throws an
Errorthat names it, because in Elm such a message could not even be written.
update must never change the model it receives: the tests freeze it, so assigning to it fails. The starter code changes the model in place, the habit that The Elm Architecture rules out.
Starter code · cart.mjs
/** The next cart for one message: Add, Remove or Clear. It never changes `model`. */
export function update(msg, model) {
// Replace this: it changes the model it was given, and it forgets the Remove and Clear rules.
if (msg.tag === 'Add') {
const line = model.lines.find((l) => l.item === msg.item);
if (line) line.quantity += 1;
else model.lines.push({ item: msg.item, quantity: 1 });
}
return model;
} The sample tests · cart.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { update } from './cart.mjs';
/** A deep-frozen copy: in a module, assigning to it or pushing onto it throws a TypeError. */
function frozen(value) {
if (typeof value === 'object' && value !== null) {
for (const v of Object.values(value)) frozen(v);
Object.freeze(value);
}
return value;
}
const empty = () => frozen({ lines: [] });
/** Runs messages through update, as the runtime would, freezing each model on the way. */
function play(messages, start = empty()) {
return messages.reduce((model, msg) => frozen(update(msg, model)), start);
}
test('Add makes a line, then counts up', () => {
assert.deepEqual(play([{ tag: 'Add', item: 'chai' }]), { lines: [{ item: 'chai', quantity: 1 }] });
assert.deepEqual(play([{ tag: 'Add', item: 'chai' }, { tag: 'Add', item: 'samosa' }, { tag: 'Add', item: 'chai' }]), {
lines: [
{ item: 'chai', quantity: 2 },
{ item: 'samosa', quantity: 1 },
],
});
});
test('Remove counts down, and a line at 0 disappears', () => {
const two = play([{ tag: 'Add', item: 'chai' }, { tag: 'Add', item: 'chai' }, { tag: 'Add', item: 'samosa' }]);
assert.deepEqual(play([{ tag: 'Remove', item: 'chai' }], two), { lines: [{ item: 'chai', quantity: 1 }, { item: 'samosa', quantity: 1 }] });
assert.deepEqual(play([{ tag: 'Remove', item: 'samosa' }], two), { lines: [{ item: 'chai', quantity: 2 }] });
assert.deepEqual(play([{ tag: 'Remove', item: 'vada' }], two), two);
});
test('Clear empties the cart', () => {
assert.deepEqual(play([{ tag: 'Add', item: 'chai' }, { tag: 'Clear' }]), { lines: [] });
});
test('the model given is never changed', () => {
const before = play([{ tag: 'Add', item: 'chai' }]);
const after = update({ tag: 'Add', item: 'chai' }, before);
assert.deepEqual(before, { lines: [{ item: 'chai', quantity: 1 }] });
assert.deepEqual(after, { lines: [{ item: 'chai', quantity: 2 }] });
});
test('an unknown message is refused', () => {
assert.throws(() => update({ tag: 'Checkout' }, empty()), /Checkout/);
}); A hint
Build the next model instead of editing this one: model.lines.map(...) to change one line into a new object { ...line, quantity: line.quantity + 1 }, filter to drop a line at 0, and [...model.lines, { item, quantity: 1 }] to add one. A switch on msg.tag with a default that throws covers the last rule.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Last, a self-check across the architecture, commands, decoders, ports and safe text. Your shakiest of those five comes up first, and each choice you make is answered with the reason it is right or what it misses:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): What are the parts of The Elm Architecture, and who calls them?
A program has a model, the whole state; a message type listing everything that can happen; update, which takes a
message and the model and returns the next model with a command; view, which turns a model into a description of
the page; and subscriptions, which name the outside events the program listens to. Your code calls none of them:
the runtime does. It calls init once, then view with each new model, and update whenever a message arrives from
a click, a subscription or the result of a command, and it keeps the model itself. Commands and subscriptions are
values that describe what should happen, and the runtime carries them out. That is why update is pure and can be
tested alone, as the lesson’s Replay did with five messages.
Two longer questions, one about a port that crashes and one about a server that changes its JSON:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
Your Elm app shows a payment status that a third-party JavaScript library reports through a port. In production some sessions stop updating, and the console shows "Trying to send an unexpected type of value through port". What is going on, and how do you make that border robust?
-
Mid to staff
The orders API is moving to a new response format with prices in a nested object, and for a few weeks the server will send both formats. Your Elm app decodes orders. How do you change the decoders, test them and roll out without errors for either format?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the four pieces of The Elm Architecture?
The model, holding the whole state; a Msg type listing everything that can happen; update, giving the next model for a message; and view, describing the page for a model.
-
Who calls update and view in an Elm program?
The runtime. It calls init once, view for each new model and update for each message, and it keeps the model: your code only returns values, so update can be tested on its own.
-
How do Browser.sandbox and Browser.element differ?
Both own one node of a page. sandbox has no commands, subscriptions, flags or ports; element adds Cmd, Sub, flags and ports.
-
In what order do the results of a Cmd.batch arrive?
In no promised order: all the commands go to the runtime at once, and Batch's first, second and third came back as third, second, first. Send one message that holds everything in order.
-
What did the Elm decoder report for an order whose second price was sent as "1450"?
A failure at json.items[1].paise, Expecting an INT, naming the bad field. JavaScript instead turned the sum 1200 + "1450" into the text 12001450.
-
Why is Decode.maybe risky for an optional field?
It reads a value of the wrong type as Nothing, as quietly as a missing one, so "note": 42 passed with no note. A decoder that allows only a missing field or null refuses it.
-
What happens when JavaScript sends the text '1450' into an Elm port typed Int?
send throws in the JavaScript that called it. A port of type Json.Decode.Value lets Elm decode the value and turn a bad one into a message, as TillSafe did.
-
Why must a port subscriber show user text with textContent and never innerHTML?
innerHTML parses the text as markup, so Asha <b>VIP</b> becomes a bold element, the start of cross-site scripting. textContent makes a text node, as Elm's own Html.text does.
Key takeaways
- A program is a model, messages,
updateandview; the runtime calls them and keeps the model, soupdateis a pure function that a test can call, asReplaydid with five clicks. - Commands and subscriptions are values the runtime carries out;
Cmd.batchpromises no order, and the three commands ofBatcharrived as third, second, first. - A decoder checks JSON at the border and names the path of the first bad value, such as
json.items[1].paise;Decode.maybehides a value of the wrong type, so optional fields need a stricter decoder. - Flags and typed ports throw in JavaScript when a value has the wrong type; ports and flags of type
Json.Decode.Valuelet Elm decode the value and handle a bad one as a message. - Elm’s virtual DOM keeps text as text, but a port subscriber must use
textContent, neverinnerHTML, for values that came from users.
Terms in this lesson
- command (Cmd)
- A value of type Cmd Msg that asks the Elm runtime to do something outside the program, such as send an HTTP request or a value through a port. update returns it; the runtime carries it out and may answer with a message.
- cross-site scripting (XSS)
- A weakness in which text from a user or another site reaches a page as markup or script instead of as text, so the browser runs it with the page's rights. Writing such text with textContent instead of innerHTML prevents it.
- flags
- The one value JavaScript passes to an Elm program when it starts it, as Elm.Main.init({ flags: … }). init receives it; a value of the wrong type makes init throw unless the flags are declared as Json.Decode.Value and decoded.
- JSON decoder
- An elm/json value of type Decoder a that describes the shape a JSON value must have and turns it into an Elm value of type a, or into an error that names the path of the first value that did not fit.
- subscription (Sub)
- A value of type Sub Msg that names outside events a program wants to hear about, such as clock ticks or values sent to a port. The runtime turns each event into a message for update.
References
- An Introduction to Elm: The Elm Architecture (Elm)
- elm/browser: Browser (sandbox, element, document, application) (Elm)
- elm/core: Platform.Cmd (batch) (Elm)
- elm/core: Platform.Sub (Elm)
- elm/json: Json.Decode (Elm)
- elm/json: Json.Encode (Elm)
- An Introduction to Elm: Flags (Elm)
- An Introduction to Elm: Ports (Elm)
- An Introduction to Elm: Custom Elements (Elm)
- elm/virtual-dom 1.0.5 source: Elm/Kernel/VirtualDom.js (Elm)
- OWASP Cheat Sheet Series: Cross Site Scripting Prevention (OWASP Foundation)
- Node: textContent property (MDN Web Docs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress