Language Atlas (mini-courses for more languages) Module 12 – Elm and The Elm Architecture
Elm 0.19.3: install, build and run headlessly
Install Elm 0.19.3 and check the download, start a project with elm init, compile it to JavaScript and run it under Node.js through Platform.worker.
What you will learn
- Install Elm 0.19.3 from its release, check the file against its published SHA-256 and read the version
- Create a project with elm init and explain what elm.json and the ~/.elm package cache each hold
- Compile a module to JavaScript with elm make, with and without --optimize, and compare the two files
- Run an Elm program without a browser, through Platform.worker and a port under Node.js
- Decide whether Elm fits a front end from what its compiler checks and what that costs
Before you start
What you’ll learn · Before you start
What you will learn
- Install Elm 0.19.3 from its release, check the file against its published SHA-256 and read the version
- Create a project with elm init and explain what elm.json and the ~/.elm package cache each hold
- Compile a module to JavaScript with elm make, with and without --optimize, and compare the two files
- Run an Elm program without a browser, through Platform.worker and a port under Node.js
- Decide whether Elm fits a front end from what its compiler checks and what that costs
Before you start
On this page
A download of 15,126,132 bytes holds the whole Elm compiler. In this lesson’s recorded sessions it became one program of 61,507,360 bytes, which turned a 58-line program into a JavaScript file of 65,875 bytes, and Node.js ran that file with no browser at all and printed a receipt for 6,750 paise.
Here you install Elm 0.19.3 and check the download, start a project and read where its package versions are kept,
compile it with and without --optimize, run it headlessly through a port, and then weigh what Elm’s compiler checks
for you against what that costs, so you can say where Elm fits and where it does not.
Elm is one program, and you check it before you run it
The 0.19.3 release on GitHub offers five binaries and two installers. For a Mac with Apple silicon the file is
elm-0.19.3-mac-arm.gz, 15,126,132 bytes, and the release page lists its SHA-256 digest beside it. The guide points
Mac and Windows users to an installer; the compiler’s own install notes for macOS do the same job in four commands:
download with curl, gunzip, chmod +x, and a move into a folder on PATH. Follow them, with the two checks that
send you back a step:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Download. The 0.19.3 release offers elm-0.19.3-mac-arm.gz for Apple silicon, 15,126,132 bytes, and lists its SHA-256 digest beside it. The install notes save it as elm.gz.
- Compare the digest. Put the published digest and the file name on one line and give it to shasum -a 256 -c. It works out the digest of the file you have and prints OK when the two are equal.
- Digits differ. A different digest means a different file: a download cut short, or not the file the release published. Delete it and download it again before you run anything.
- Unpack. gunzip replaces elm.gz with elm, one program of 61,507,360 bytes, and chmod +x lets the shell run it. The compiler, the package manager, the REPL and the reactor are all in that file.
- PATH. Move elm into a folder that comes first on PATH. The install notes use /usr/local/bin with sudo; a folder of your own, such as ~/.local/bin, needs no sudo. command -v elm names the file the shell will run.
- The versions. elm --version printed 0.19.3, node --version printed v24.21.0 and elm-test --version printed 0.19.3-0: the pins of every session in this module.
- Another elm answers. If elm --version prints an older number, an earlier install comes first on PATH, from npm or an installer. command -v elm says which file that is; put the new folder in front of it.
- A first project. elm init writes elm.json and an empty src folder, after asking first. The next section runs it twice, with and without the package cache.
The recorded session starts on a computer with Node.js and no Elm, with the file that curl -L -o elm.gz … saved.
Each $ line is a command you would type, and below it is what the command printed:
"""Installs Elm 0.19.3 from its GitHub release on a computer that has Node.js and no Elm, checks the download against
the SHA-256 digest that the release page lists beside the file, and reads the versions (session.py plays it).
Downloading needs the network, which the session keeps switched off, so it starts with the file that
curl -L -o elm.gz https://github.com/elm/compiler/releases/download/0.19.3/elm-0.19.3-mac-arm.gz saved. elm-test comes
from npm (npm install --global elm-test@0.19.3-0 on a computer with a network)."""
from session import download, run
download("elm-0.19.3-mac-arm.gz", saved_as="elm.gz")
run("""
! command -v elm
wc -c < elm.gz
echo "a426e47d14cba0588aa81475adc32ba24bbf045209d0c560d0a89a5c4f3c34d0 elm.gz" | shasum -a 256 -c
gunzip elm.gz
chmod +x elm
mkdir -p $HOME/.local/bin
mv elm $HOME/.local/bin/
export PATH=$HOME/.local/bin:$PATH
command -v elm
elm --version
wc -c < $HOME/.local/bin/elm
node --version
elm-test --version
""", elm_on_path=False) Output
$ command -v elm [exit status 1] $ wc -c < elm.gz 15126132 $ echo "a426e47d14cba0588aa81475adc32ba24bbf045209d0c560d0a89a5c4f3c34d0 elm.gz" | shasum -a 256 -c elm.gz: OK $ gunzip elm.gz $ chmod +x elm $ mkdir -p $HOME/.local/bin $ mv elm $HOME/.local/bin/ $ export PATH=$HOME/.local/bin:$PATH $ command -v elm ~/.local/bin/elm $ elm --version 0.19.3 $ wc -c < $HOME/.local/bin/elm 61507360 $ node --version v24.21.0 $ elm-test --version 0.19.3-0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 install_session.py
command -v elm failed first: nothing called elm was on PATH. shasum -a 256 -c read one line, the published
digest and a file name, worked out the digest of elm.gz and printed OK because the two were equal. gunzip
replaced elm.gz with elm, which chmod +x made runnable. The install notes move it into /usr/local/bin with
sudo; the session uses ~/.local/bin, a folder of your own, and puts it first on PATH, so command -v elm names
that file. Unpacked, the compiler is 61,507,360 bytes, about four times the download, and it holds elm make,
elm install, elm repl and elm reactor in one file.
The last three lines are this module’s toolchain pin: The exact version of the compiler, interpreter or SDK that an example was built and run with, such as Tcl 9.0.4 or the .NET SDK 10.0.401. Another version may print something else for the same file. More in “How the Language Atlas works”: 0.19.3, Node.js v24.21.0,
which runs the compiled programs and elm repl, and 0.19.3-0 for elm-test, the test runner, which is not part
of the compiler and comes from npm.
Releases after Elm 0.19.3
The 0.19.3 release notes say there are no language changes, so a project moves to it by changing elm-version in
elm.json, and the post that announced it plans more small releases on the way to 1.0. A later release can print
other messages and file sizes for the same code, so compare your output with these sessions only when
elm --version prints 0.19.3.
Checkpoint
Check: Installing Elm and checking the download
elm init writes elm.json, and only the first build needs the package site
elm init writes the one file every Elm project has, elm.json, and a src folder. To fill in the versions it needs
the list of every published package, which comes from package.elm-lang.org. The session runs it twice with the
network switched off: first with an empty package cache in ~/.elm, as on a computer that has never built Elm, then
with the cache a computer has after its first builds. Both times elm init asks before it writes anything, and the
session answers y:
"""Creates a project with elm init twice: first on a computer whose package cache, ~/.elm, is empty, then with the cache
that a computer has after it has built a project once, both with the network switched off (session.py plays it).
elm init asks before it writes elm.json, and the session answers y."""
from session import cache, run
run("""
mkdir first
cd first
! elm init
""", stdin="y\n")
cache()
run("""
# ~/.elm now holds the packages that the first builds downloaded
elm init
ls
cat elm.json
ls $HOME/.elm/0.19.3/packages/elm
""", stdin="y\n") Output
$ mkdir first
$ cd first
$ elm init
Hello! Elm projects always start with an elm.json file. I can create them!
Now you may be wondering, what will be in this file? How do I add Elm files to
my project? How do I see it in the browser? How will my code grow? Do I need
more directories? What about tests? Etc.
Check out <https://elm-lang.org/0.19.3/init> for all the answers!
Knowing all that, would you like me to create an elm.json file now? [Y/n]: -- PROBLEM LOADING PACKAGE LIST ------------------------------------------------
I need the list of published packages before I can start initializing projects,
so I tried to fetch:
https://package.elm-lang.org/all-packages
But my HTTP library is giving me the following error message:
ConnectionFailure Network.Socket.getAddrInfo (called with preferred socket type/protocol: AddrInfo {addrFlags = [AI_ADDRCONFIG], addrFamily = AF_UNSPEC, addrSocketType = Stream, addrProtocol = 0, addrAddress = 0.0.0.0:0, addrCanonName = Nothing}, host name: "package.elm-lang.org", service name: "443"): does not exist (nodename nor servname provided, or not known)
Are you somewhere with a slow internet connection? Or no internet? Does the link
I am trying to fetch work in your browser? Maybe the site is down? Does your
internet connection have a firewall that blocks certain domains? It is usually
something like that!
[exit status 1]
# ~/.elm now holds the packages that the first builds downloaded
$ elm init
Hello! Elm projects always start with an elm.json file. I can create them!
Now you may be wondering, what will be in this file? How do I add Elm files to
my project? How do I see it in the browser? How will my code grow? Do I need
more directories? What about tests? Etc.
Check out <https://elm-lang.org/0.19.3/init> for all the answers!
Knowing all that, would you like me to create an elm.json file now? [Y/n]: Okay, I created it. Now read that link!
$ ls
elm.json
src
$ cat elm.json
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.3",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.1"
},
"indirect": {
"elm/json": "1.1.4",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.5"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}
$ ls $HOME/.elm/0.19.3/packages/elm
browser
bytes
core
html
json
random
time
url
virtual-dom
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 project_session.py
The first try stopped with PROBLEM LOADING PACKAGE LIST and exit status 1: it could not fetch all-packages, and
it wrote nothing. The second worked offline, because ~/.elm already held the list and the packages. The compiler
keeps that cache in ~/.elm, or in the folder that the ELM_HOME variable names, and every project on the computer
shares it: here it also holds bytes and random, packages that only a later lesson’s tests use.
Read elm.json from the top. "type": "application" says this is a program, not a package. "source-directories"
says where the modules are. "elm-version" is the exact compiler, 0.19.3. Then come the dependencies: three
direct ones, which your code may import, and four indirect ones, which those three need. Every one has a
single exact version: elm/core is 1.0.5, not a range. The compiler’s documentation for this file says that the
exact versions make elm.json double as a
A file that records the exact version of every package a build uses, so that every later build gets the same ones. An Elm application's elm.json is one; npm keeps a separate package-lock.json., and that the indirect ones are
listed so that builds are reproducible and you can see everything you depend on. A package’s own elm.json is
different: elm/browser’s, in the cache, asks for ranges such as "1.0.0 <= v < 2.0.0", which an application then
resolves to one version each. Step through what happened, and what a computer with a network does:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A question first. elm init explains what elm.json is and asks before it writes anything. The session answers y, as you would by typing it.
- The list of packages. To choose versions of elm/core, elm/browser and elm/html, elm init needs the list of every published package and version, so it asks the package site for it.
- Offline, empty cache. With the network off and nothing in ~/.elm, the request fails and elm init stops with PROBLEM LOADING PACKAGE LIST and exit status 1. No elm.json is written.
- Online. On a computer with a network the site answers, and elm keeps the list in ~/.elm, so that later commands can choose versions without asking again.
- Exact versions. elm.json lists three direct dependencies and four indirect ones, each with one exact version: elm/core 1.0.5, elm/json 1.1.4 and so on. The file is its own lock file.
- The first build. The first elm make downloads the source of each package into ~/.elm/0.19.3/packages and compiles it once. The cache is shared by every project on the computer.
- Every later init and build. With the cache in place, elm init works offline, as the session's second try did, and elm make prints Dependencies ready! without asking the site anything.
Keep elm.json, leave out elm-stuff
Commit elm.json, since it fixes every version of the build. elm make writes compiled modules into an
elm-stuff folder next to it, a cache it rebuilds whenever it is missing, so it stays out of version control, as
the build folders of other languages do.
Checkpoint
Check: elm.json and the package cache
elm make writes one JavaScript file, and the optimize flag changes little on a small one
orders/ is a project with the elm.json that elm init wrote and one module, src/Orders.elm: two lines of an
order priced in paise, added up and sent out as a receipt. The session builds it twice, plainly and with
--optimize, measures each file and runs it:
"""Builds the Orders worker without and with --optimize, runs each build under Node.js through the port, measures both
files and shows the two lines that end the compiled file; late.cjs then subscribes too late (session.py plays it)."""
from session import cache, run
cache()
run("""
cd orders
elm make src/Orders.elm --output=build/orders.js
wc -c < build/orders.js
node run.cjs
elm make src/Orders.elm --optimize --output=build/orders.js
wc -c < build/orders.js
gzip -9 < build/orders.js | wc -c
tail -n 2 build/orders.js
node run.cjs
node late.cjs
""") Output
$ cd orders
$ elm make src/Orders.elm --output=build/orders.js
Dependencies ready!
Success! Compiled 1 module.
Orders ───> build/orders.js
$ wc -c < build/orders.js
65875
$ 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.
chai 2 x ₹12.00 ₹24.00
samosa 3 x ₹14.50 ₹43.50
total ₹67.50
$ elm make src/Orders.elm --optimize --output=build/orders.js
Success!
Orders ───> build/orders.js
$ wc -c < build/orders.js
65308
$ gzip -9 < build/orders.js | wc -c
15746
$ tail -n 2 build/orders.js
_Platform_export({'Orders':{'init':$author$project$Orders$main(
$elm$json$Json$Decode$succeed(0))(0)}});}(this));
$ node run.cjs
chai 2 x ₹12.00 ₹24.00
samosa 3 x ₹14.50 ₹43.50
total ₹67.50
$ node late.cjs
messages that reached a subscriber added in a timer: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
The plain build printed Dependencies ready! and Success! Compiled 1 module. and wrote build/orders.js, 65,875
bytes. Running it printed a warning first: Compiled in DEV mode, with a link to the compiler’s advice on
optimising. The second build printed only Success!: the module was already compiled in elm-stuff, so there was
nothing new to compile. Its file is 65,308 bytes, 567 bytes or 0.9 % smaller, 15,746 bytes after gzip -9, and it
printed the same receipt without the warning.
So what does --optimize do? elm make --help says it shortens record field names and unboxes values, and the post
that introduced it in 0.19 says the renaming tends to make files 5 % to 10 % smaller. Orders is too small to show
that: most of its file is the runtime and the core functions it calls, and it has three record fields of its own.
Two things happen in every
build, optimised or not. The types are checked before anything is written, and
dead code elimination: A compiler step that leaves out every function a program can never call. Elm does it in every build, function by function, across the application and all its packages, because Elm functions cannot be redefined while the program runs. drops every function that main cannot reach, which the
same post says works across all packages because each one on the package site is written entirely in Elm. The big
saving is a minifier, which the compiler’s notes on optimising add as a second step; the project lesson of this
module measures it. Step through one build:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Your module. elm make reads src/Orders.elm, the module named on the command line, and every module it imports, here only modules of elm/core.
- Packages from the cache. The packages come from ~/.elm, already compiled, so the build needs no network: Dependencies ready!
- Types first. Nothing is written until every type fits. A mismatch, such as a number added to a string, stops the build with a message and exit status 1; the next lesson shows one.
- Only what main reaches. Elm drops every function that main cannot reach, in your code and in the packages, so the file holds the runtime and the functions the receipt uses, not all of elm/core.
- One file. The build writes build/orders.js, 65,875 bytes: the runtime, the used functions and your module, wrapped so that it sets Elm.Orders on whatever loads it.
- Kept for next time. The compiled module also goes into elm-stuff/. The --optimize build found it there and printed only Success!, without Compiled 1 module.
- With --optimize. The optimised file is 65,308 bytes: record fields such as init and update get short names, and the warning about DEV mode is gone. On a program this small that saves under 1 %.
- Node.js runs it. node run.cjs loads the file, starts the worker and prints the three lines of the receipt, the same from both builds.
Key idea
elm make checks every type, keeps only what main reaches and writes one JavaScript file; --optimize renames
record fields on top of that, which saves little on a small program and is a step before minifying, not instead
of it.
Checkpoint
Check: What elm make and --optimize write
Platform.worker runs Elm without a page, and a port carries the result out
A browser program draws a view. Orders has none: its main is a Platform.worker: The kind of Elm program that has no view, only init, update and subscriptions. It runs under Node.js or in a page without drawing anything and talks to JavaScript through ports, so Elm code can run headlessly.,
which the core library’s documentation calls a program with no user interface. It takes three functions: init
starts it, update handles messages and subscriptions says which outside events it listens to. Its only way to
show anything is a port: A typed channel between Elm and JavaScript, declared with the port keyword in a port module. A port that returns Cmd sends values out to JavaScript subscribers; one that returns Sub brings values sent from JavaScript in as messages.: port report : String -> Cmd msg declares a command that sends
a string to JavaScript, and the first line, port module Orders, is what allows a module to declare one:
port module Orders exposing (main)
{-| Adds up a small order priced in paise and sends a receipt out through the
port `report`. A Platform.worker has no view, so Node.js can run it.
-}
port report : String -> Cmd msg
type alias Line =
{ item : String
, paise : Int
, quantity : Int
}
order : List Line
order =
[ { item = "chai", paise = 1200, quantity = 2 }
, { item = "samosa", paise = 1450, quantity = 3 }
]
lineTotal : Line -> Int
lineTotal line =
line.paise * line.quantity
rupees : Int -> String
rupees paise =
"₹" ++ String.fromInt (paise // 100) ++ "." ++ String.padLeft 2 '0' (String.fromInt (modBy 100 paise))
receipt : List Line -> String
receipt lines =
let
row line =
String.padRight 8 ' ' line.item
++ String.fromInt line.quantity
++ " x "
++ rupees line.paise
++ " "
++ rupees (lineTotal line)
total =
List.sum (List.map lineTotal lines)
in
String.join "\n" (List.map row lines ++ [ "total" ++ String.repeat 15 " " ++ rupees total ])
main : Program () () Never
main =
Platform.worker
{ init = \_ -> ( (), report (receipt order) )
, update = \_ model -> ( model, Cmd.none )
, subscriptions = \_ -> Sub.none
} init ignores its flags and returns two things: the model, (), which holds nothing, and the command
report (receipt order). update and subscriptions do nothing, since nothing ever sends this program a message.
The JavaScript side is three lines of code:
// Starts the Orders worker of build/orders.js and prints what it sends through its port `report`.
const { Elm } = require('./build/orders.js');
const app = Elm.Orders.init();
app.ports.report.subscribe((text) => console.log(text)); Step through one run, the one the session recorded twice:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Load the file. require('./build/orders.js') runs the compiled file. It hands its programs to the this of the module that loads it, so run.cjs gets an object Elm with one key, Orders.
- Start the program. run.cjs calls Elm.Orders.init(). No node of a page is given, because a worker has no view to draw.
- Your init runs. The runtime calls the init of Platform.worker with the flags, here the unit value ().
- A model and a command. init returns the pair ((), report (receipt order)): a model with nothing in it, and a command that asks the runtime to send one string out through the port report.
- Subscribe in time. init() has returned app, and run.cjs subscribes to app.ports.report on the next line, in the same turn of the event loop, before the runtime delivers the command.
- The string arrives. The runtime converts the Elm String to a JavaScript string and calls every function subscribed to report with it.
- Printed. console.log prints the three lines: chai, samosa and a total of 67.50 rupees. The program sends nothing else, so Node.js exits when nothing is left to do.
run.cjs uses CommonJS on purpose. The compiled file is one function called with this, and it puts Elm on that
object, as the two lines that end build/orders.js show in the session: _Platform_export({'Orders': …}) and
}(this));. Under require, this is the module’s exports, and in a browser <script> it is the window. Loaded
as an ES module, from a folder whose package.json says "type": "module", the same file finds no this to put
Elm on.
Subscribing to a port after a delay
The receipt comes from init, and the runtime delivers it once the code that called init() has finished its
turn. run.cjs subscribes on the very next line and gets it; the session’s last command, node late.cjs,
subscribes from a timer and reports 0 messages. Subscribe to every port right after init() returns.
Checkpoint
Check: Platform.worker and an outgoing port
What the compiler checks for you, and what that costs
Elm’s homepage leads with no runtime exceptions, friendly error messages and great performance, and lists small
assets and enforced semantic versioning among its features. Two of those you have now seen at work: a compiler that
checks every type before it writes a line, and a build that keeps only what main reaches. The third is about
packages. elm diff compares the types of two versions of a package, and the homepage says Elm uses that check to
make every package follow semantic versioning, so a patch release cannot remove a function you call.
These checks come at a price, and the price is the border with JavaScript. Elm code reaches JavaScript only through flags at start-up, ports and custom elements, and every package on the package site is written in Elm, so an npm library is used by writing a port for it. The language also moves slowly and with care: the four releases of 0.19 changed the language once, at 0.19.0, and each later release says there are no language changes. Walk through them:
- 0.19.0
Small assets by default language changes
Every build drops the functions that main cannot reach, and --optimize also shortens record field names. Code written for 0.18 had to be upgraded: the last release that changed the language.
- 0.19.1
Clearer errors, faster rebuilds no language changes
No language changes: better parse error messages, faster incremental compiles and file locks on the cache. Three bug fixes can stop old code compiling, such as a port module with no ports.
- 0.19.2
Faster builds no language changes
Less allocation while parsing: on 850,000 lines, 20 % less copying in garbage collection, 10 % lower peak memory and 7 % faster overall. No language changes.
- 0.19.3
Correct by construction this course
More precise types inside the compiler, and better primitives for concurrency, binary data and file locking, as a base for later releases. No language changes. This module runs it.
The timeline has 4 events, from 0.19.0 to 0.19.3.
“No runtime exceptions” also has edges, and the next two lessons record them: modBy 0 stops a program with an
error, and so does a value of the wrong type sent through a port. The rule holds inside your Elm code; at the border,
you check.
So Elm fits front ends where wrong states and crashes cost more than a small ecosystem does: forms, flows with many
steps, dashboards a team keeps for years. It can start small, because Browser.element takes over one node of an
existing page, and the element documentation says many companies that use Elm began that way. Three questions about
the work decide most of the choice; answer them in the table:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to build it | Errors in UI logic | npm UI libraries | Fits an existing app | Package updates |
|---|---|---|---|---|
| A whole app in Elm Looks like: Browser.document or application | Good no null and every case handled; a few crashes remain, such as modBy 0 | Poor reached only through ports or custom elements | Poor a rewrite of the pages it replaces | Good the package site checks that each release follows semantic versioning |
| One Elm element Looks like: Browser.element in one node | Good the same checks, inside the element | Mixed the page keeps its libraries; the element talks to them through ports | Good takes over one node of a page that stays as it is | Good the same enforced versioning for the Elm part |
| JavaScript or TypeScript Looks like: the framework you use now | Mixed TypeScript catches null with strictNullChecks; a missed case needs your own check | Good any npm package, imported directly | Good no change to the build or the team | Mixed semantic versioning is a promise of each author |
How the answers decide
- “Is there a running JavaScript app that this work goes into?” Yes, an existing app: rules out A whole app in Elm. Replacing a running app is a rewrite; Browser.element lets Elm take over one node and grow from there.
- “Does the work lean on npm UI libraries, such as a chart or a rich text editor?” Yes, several: adds a caveat to A whole app in Elm and One Elm element. Elm packages are written entirely in Elm, so each npm library sits behind a port or a custom element that you write and test.
- “Does the logic have many states that must never mix, such as a checkout or a booking flow?” Yes: adds a caveat to JavaScript or TypeScript. Custom types and case make an impossible mix of states a compile error in Elm; in TypeScript it takes discipline and settings such as strictNullChecks.
In short: a new front end with many states suits a whole Elm app; an existing app suits one Elm element that grows; and work that leans on many npm UI libraries stays easiest in JavaScript or TypeScript, or keeps those libraries behind ports.
Checkpoint
Check: Where Elm fits, and what it costs
Practise: print paise as rupees, then read elm.json
Two steps, in JavaScript, because the page runs JavaScript tests in your browser. The first writes the worker’s
rupees function in JavaScript, digit for digit, so that you can check any amount the receipt might print without
building Elm at all. The second reads the elm.json files of this lesson the way the compiler reads them: where each
package is listed, which versions an application pins, and why a package’s own file, with its ranges, is not a lock
file. Each step has sample tests and a first hint:
Code lab · 2 steps
Format money, then read a project file
Turn whole paise into the text the Orders worker printed, then say where an elm.json lists each package and which versions it pins.
Exercise · Easy · JavaScript
Print paise as rupees, the way Orders.elm does
Orders.elm keeps every amount as a whole number of paise and only turns it into text at the end: paise // 100 rupees, a dot, then modBy 100 paise padded with zeros to two digits. Write the same function in JavaScript: rupees(paise) in rupees.mjs returns the text the worker printed, such as "₹67.50" for 6750.
paiseis a whole number of 0 or more; the amount has no thousands separators: 123456 paise is"₹1234.56".- Always two digits after the dot: 5 paise is
"₹0.05"and 1200 paise is"₹12.00". - A negative amount or one that is not a whole number throws a
RangeError, because the worker never prints one.
The starter code divides by 100 and prints whatever JavaScript makes of the result, which is the mistake the paise avoid.
Starter code · rupees.mjs
/** The amount `paise` as rupees with two digits after the dot: rupees(6750) is "₹67.50". */
export function rupees(paise) {
// Replace this: dividing first gives "₹67.5" for 6750 and "₹0.05" only by luck.
return '₹' + paise / 100;
} The sample tests · rupees.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { rupees } from './rupees.mjs';
test('the three amounts of the receipt that Orders.elm printed', () => {
assert.equal(rupees(1200), '₹12.00');
assert.equal(rupees(4350), '₹43.50');
assert.equal(rupees(6750), '₹67.50');
});
test('always two digits after the dot', () => {
assert.equal(rupees(0), '₹0.00');
assert.equal(rupees(5), '₹0.05');
assert.equal(rupees(99), '₹0.99');
assert.equal(rupees(100), '₹1.00');
assert.equal(rupees(101), '₹1.01');
});
test('large amounts have no separators', () => {
assert.equal(rupees(123456), '₹1234.56');
assert.equal(rupees(1000000), '₹10000.00');
});
test('negative amounts and fractions of a paisa are refused', () => {
assert.throws(() => rupees(-5), RangeError);
assert.throws(() => rupees(12.5), RangeError);
}); A hint
Split the amount into two whole numbers before you make any text: Math.trunc(paise / 100) rupees and paise % 100 paise, which for amounts of 0 or more are what Elm's // and modBy give. Then pad the paise with String(rest).padStart(2, '0'), as String.padLeft 2 '0' does in the worker.
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
Read an application's elm.json
elm init wrote an elm.json whose two dependency blocks name every package with one exact version, and elm-test init adds a third and a fourth list, the test dependencies. Write two functions in deps.mjs that read the text of such a file:
whereListed(text, name)returns where the file lists the packagename:"direct","indirect","test-direct"or"test-indirect", ornullwhen it lists it nowhere.pinned(text)returns every package of the file with its version as[name, version]pairs, test dependencies included, sorted by name. It throws anErrorwhen the file is a package'selm.json("type": "package"), whose dependencies are ranges such as"1.0.0 <= v < 2.0.0", not versions.
The sample tests use the elm.json of the lesson's session, the same file after elm-test init, and the elm.json of the elm/browser 1.0.2 package from the package cache.
Starter code · deps.mjs
/** Where the elm.json text `text` lists the package `name`: "direct", "indirect", "test-direct", "test-indirect" or null. */
export function whereListed(text, name) {
// Replace this: it only looks at the direct dependencies.
const json = JSON.parse(text);
return name in json.dependencies.direct ? 'direct' : null;
}
/** Every package of an application's elm.json with its exact version, as [name, version] pairs sorted by name. */
export function pinned(text) {
// Replace this: it forgets the indirect and test dependencies, and accepts a package's elm.json.
const json = JSON.parse(text);
return Object.entries(json.dependencies.direct);
} The sample tests · deps.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { whereListed, pinned } from './deps.mjs';
// The elm.json that elm init wrote in the lesson's session.
const app = JSON.stringify({
type: 'application',
'source-directories': ['src'],
'elm-version': '0.19.3',
dependencies: {
direct: { 'elm/browser': '1.0.2', 'elm/core': '1.0.5', 'elm/html': '1.0.1' },
indirect: { 'elm/json': '1.1.4', 'elm/time': '1.0.0', 'elm/url': '1.0.0', 'elm/virtual-dom': '1.0.5' },
},
'test-dependencies': { direct: {}, indirect: {} },
}, null, 4);
// The same file after elm-test init added the test runner's packages.
const withTests = JSON.stringify({
...JSON.parse(app),
'test-dependencies': {
direct: { 'elm-explorations/test': '2.2.1' },
indirect: { 'elm/bytes': '1.0.8', 'elm/random': '1.0.0' },
},
}, null, 4);
// elm/browser 1.0.2's own elm.json, from ~/.elm/0.19.3/packages/elm/browser/1.0.2/elm.json.
const browserPackage = JSON.stringify({
type: 'package',
name: 'elm/browser',
version: '1.0.2',
'elm-version': '0.19.0 <= v < 0.20.0',
dependencies: {
'elm/core': '1.0.0 <= v < 2.0.0',
'elm/html': '1.0.0 <= v < 2.0.0',
'elm/json': '1.0.0 <= v < 2.0.0',
'elm/time': '1.0.0 <= v < 2.0.0',
'elm/url': '1.0.0 <= v < 2.0.0',
'elm/virtual-dom': '1.0.2 <= v < 2.0.0',
},
'test-dependencies': {},
}, null, 4);
test('direct and indirect dependencies of the session elm.json', () => {
assert.equal(whereListed(app, 'elm/core'), 'direct');
assert.equal(whereListed(app, 'elm/html'), 'direct');
assert.equal(whereListed(app, 'elm/json'), 'indirect');
assert.equal(whereListed(app, 'elm/virtual-dom'), 'indirect');
assert.equal(whereListed(app, 'elm/http'), null);
});
test('test dependencies after elm-test init', () => {
assert.equal(whereListed(withTests, 'elm-explorations/test'), 'test-direct');
assert.equal(whereListed(withTests, 'elm/random'), 'test-indirect');
assert.equal(whereListed(withTests, 'elm/core'), 'direct');
});
test('every package with its exact version, sorted by name', () => {
assert.deepEqual(pinned(app), [
['elm/browser', '1.0.2'],
['elm/core', '1.0.5'],
['elm/html', '1.0.1'],
['elm/json', '1.1.4'],
['elm/time', '1.0.0'],
['elm/url', '1.0.0'],
['elm/virtual-dom', '1.0.5'],
]);
assert.deepEqual(pinned(withTests).map(([name]) => name), [
'elm-explorations/test',
'elm/browser',
'elm/bytes',
'elm/core',
'elm/html',
'elm/json',
'elm/random',
'elm/time',
'elm/url',
'elm/virtual-dom',
]);
});
test('a package lists ranges, so pinned refuses it', () => {
assert.throws(() => pinned(browserPackage), Error);
}); A hint
JSON.parse(text) gives an object whose dependencies and test-dependencies each hold direct and indirect objects, from package name to version. Look name up in those four objects in turn. For pinned, check type first, then collect Object.entries of all four and sort the pairs by their first item.
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.
Then try the self-check on the five ideas of this lesson. It asks first about the idea you are least sure of, and explains every answer as soon as you choose it:
Practice that adapts to you
Practise this lesson
10 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): Where does an Elm application record the versions of its packages, and what is ~/.elm for?
In elm.json. Its dependencies list every package the application uses, the direct ones that its code imports and
the indirect ones that those need, each with one exact version, such as elm/core 1.0.5; test packages go in
test-dependencies the same way. Because the versions are exact, the file is its own lock file: there is no second
file to keep in step, and committing elm.json fixes the build. ~/.elm is the package cache, shared by every
project on the computer: the list of published packages and the source and compiled form of each version
downloaded so far. The ELM_HOME variable moves it. A fresh computer needs the network for its first elm init or
build; once the cache holds the packages, both work offline, as the lesson’s second elm init did.
Two longer questions, one about a build that breaks in CI and one about choosing Elm:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
Your Elm app's CI starts every job on a fresh machine. On some days the build fails at its first step with PROBLEM LOADING PACKAGE LIST, while builds on laptops work. Nobody changed elm.json. What is going on, and how do you make the CI build reliable?
-
Mid to staff
Your admin dashboard is a large JavaScript app. A team wants to write a new multi-step refund flow in Elm, and the flow must use the dashboard's date picker and its signed-in session. Do you agree, and how do you introduce Elm so that the risk stays small?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which three pins does the Elm mini-course check, and why does elm-test have its own?
elm 0.19.3, Node.js v24.21.0, which runs the compiled programs and elm repl, and elm-test 0.19.3-0. The test runner is not part of the compiler: it comes from npm.
-
What does an Elm application's elm.json record about its packages?
Every direct and indirect dependency, each at one exact version such as elm/core 1.0.5, so the file doubles as a lock file. A package's own elm.json lists ranges instead.
-
Why did elm init fail on a computer with an empty ~/.elm and no network?
It needs the list of all published packages from package.elm-lang.org. Once ~/.elm holds that list and the packages, elm init and builds work offline.
-
Which of elm.json and elm-stuff belong in version control?
elm.json, which fixes every version of the build. elm-stuff is a cache of compiled modules that elm make rebuilds whenever it is missing.
-
What does elm make --optimize do, and how much did it save on the Orders worker?
It shortens record field names and unboxes values. On a 65,875-byte worker that saved 567 bytes, 0.9 %, since most of the file is runtime and core code; a minifier does the real shrinking.
-
What does dead code elimination remove in every Elm build?
Every function that main cannot reach, across all packages, which is possible because every package on the package site is written entirely in Elm.
-
What is a Platform.worker program?
An Elm program with no user interface, built from init, update and subscriptions. It shows results only through ports, so Node.js can run it with no browser.
-
Why did late.cjs, which subscribed to the port from a timer, receive 0 messages?
The receipt comes from init, and the runtime delivers it once the code that called init() finishes its turn. Subscribe to each port right after init() returns.
Key takeaways
- Elm is one program: download the release file, check it against the digest the release page lists, unpack it and
put it first on
PATH;elm --versionmust print0.19.3before outputs can be compared with this module. - An application’s
elm.jsonnames every direct and indirect package with one exact version, so it is its own lock file, while~/.elmcaches the packages for every project and lets later inits and builds run offline. elm makechecks types, keeps only whatmainreaches and writes one JavaScript file;--optimizerenamed record fields and saved 0.9 % of a 65,875-byte worker, and a minifier is the step that shrinks it.Platform.workerruns Elm with no view, and a port carries values out to JavaScript, so Node.js can run and check an Elm program headlessly once it subscribes right afterinit().- Elm’s compiler rules out null and unhandled cases and enforces package versions, at the price of a border with JavaScript that you cross through flags, ports and custom elements, and check yourself.
Terms in this lesson
- dead code elimination
- A compiler step that leaves out every function a program can never call. Elm does it in every build, function by function, across the application and all its packages, because Elm functions cannot be redefined while the program runs.
- Platform.worker
- The kind of Elm program that has no view, only init, update and subscriptions. It runs under Node.js or in a page without drawing anything and talks to JavaScript through ports, so Elm code can run headlessly.
- port
- A typed channel between Elm and JavaScript, declared with the port keyword in a port module. A port that returns Cmd sends values out to JavaScript subscribers; one that returns Sub brings values sent from JavaScript in as messages.
References
- Elm compiler release 0.19.3 (files and digests) (Elm)
- Install instructions for macOS (elm/compiler installers) (Elm)
- An Introduction to Elm: Install Elm (Elm)
- elm.json for applications (Elm)
- elm/compiler source: Root.hs (ELM_HOME and ~/.elm) (Elm)
- How to optimize Elm code (Elm)
- Small Assets without the Headache (Elm 0.19) (Elm)
- Upgrading to 0.19.1 (Elm)
- Faster Builds with Elm 0.19.2 (Elm)
- Another step towards Elm 1.0 (Elm 0.19.3) (Elm)
- elm/core: Platform (worker) (Elm)
- An Introduction to Elm: Ports (Elm)
- elm/browser: Browser (sandbox, element, document, application) (Elm)
- Elm: a delightful language for reliable web applications (Elm)
- elm-test on npm (0.19.3-0) (npm)
- TSConfig reference: strictNullChecks (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress