Language Atlas (mini-courses for more languages) Module 12 – Elm and The Elm Architecture
Project: a bill-splitter app in Elm
Build a bill splitter in Elm: whole paise, tax and tip rounded per item, shares that always add up, tests with elm-test and a minified build.
What you will learn
- Specify a bill split in whole paise, with tax and tip rounded half up per item and a fixed rule for odd paise
- Implement the split as a pure Elm module whose shares always add up to the bill
- Test the core with elm-test, including a fuzz test, and read a failure with its shrunk counterexample
- Build a Browser.element form around the core that lists the problems it finds together, not only the first
- Optimise and minify the app for release, and measure the file at each step
Before you start
What you’ll learn · Before you start
What you will learn
- Specify a bill split in whole paise, with tax and tip rounded half up per item and a fixed rule for odd paise
- Implement the split as a pure Elm module whose shares always add up to the bill
- Test the core with elm-test, including a fuzz test, and read a failure with its shrunk counterexample
- Build a Browser.element form around the core that lists the problems it finds together, not only the first
- Optimise and minify the app for release, and measure the file at each step
Before you start
On this page
Three friends share a dinner of 31,625 paise with tax and tip, and two of the items do not split evenly. Round each share on its own and the shares can add up to a paisa more or less than the bill; the splitter in this lesson gives the odd paise out by a written rule, and its tests prove that the shares add up for every bill they try.
You will turn a specification into a pure Elm module, check it with eight elm-test tests, one of them a fuzz test that found a bug’s smallest case in the recorded run, wrap the module in a form that lists its problems together instead of stopping at the first, and ship it optimised and minified, from 135,770 bytes down to 9,282 on the wire.
The specification fixes every rounding before any code
Money code fails in its rounding, so the rules come first, written down so that the tests can hold the code to them:
| Rule | What the splitter does |
|---|---|
| Amounts | whole paise everywhere; a price typed as 145.50 becomes 14550 |
| Tax and tip | each a percentage of the item’s price, rounded half up on its own: (paise * percent + 50) // 100 |
| Line total | price + tax + tip of each item; the bill is the sum of its line totals |
| Splitting a line | equal whole parts for the item’s sharers, and the paise left over one each to the sharers in alphabetical order |
| Shares | the sum of each diner’s parts, for every diner in alphabetical order |
| Limits | prices from 0.01 to 100000.00 rupees, tax from 0 to 28 percent, tip from 0 to 25 percent |
Two rules deserve a reason. rounding half up: Rounding in which a value exactly halfway between two whole numbers goes to the larger one: 1,252.5 becomes 1,253. For whole numbers a and b, the expression (2a + b) div (2b) rounds a / b this way without any real numbers. More in “Project: a marks report in Object Pascal” with whole numbers only means that a
tax of 72.5 paise becomes 73 without a float ever appearing. The price limit keeps every quotient of the core far
below 2,147,483,647, the point past which Elm’s // wraps, as the second lesson of this module measured. Follow
one item of the dinner through the rules:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Whole paise. Every amount is a whole number of paise, so no step ever stores 145.00 as a float. The form turns the typed 145 into 14500 with parsePaise.
- Tax on the item. 5 percent of 14500 is exactly 725. For an amount such as 1450 it is 72.5, and (paise * percent + 50) // 100 rounds that half up to 73.
- Tip on the price. The tip is 10 percent of the price before tax, 1450, rounded the same way. Taking it on the price is a rule of this specification, written down so that the tests can hold the code to it.
- One line total. 14500 + 725 + 1450 is 16675 paise. The bill's total is the sum of its line totals, 31625 for the dinner.
- Equal whole shares. The item is shared by Asha and Ravi, so each gets 16675 // 2, which is 8337, and 16675 - 2 * 8337 = 1 paisa is left over.
- The odd paisa. Leftover paise go one each to the sharers in alphabetical order, so Asha gets 8338 and Ravi 8337. The two parts add up to 16675 again, and so the shares add up to the bill.
Tax rates are inputs, not advice
A restaurant meal in India is a supply that carries GST, at rates the government notifies on the recommendation of the GST Council, and the rate on a given bill depends on rules the splitter knows nothing about. So the form asks for the rate as a number from 0 to 28 percent and the code only does the arithmetic: copy the rate from the printed bill, and treat the result as a calculation, never as tax advice.
Checkpoint
Check: The rounding rules of the specification
A pure module turns a bill into shares that add up
Split.elm is the whole core: types for a bill and its items, shares, total, validate for the form, and the
two money helpers, parsePaise and rupees. It imports nothing but elm/core, and every function is a
pure function: A function whose result depends only on its arguments and that changes nothing outside itself: called twice with the same values it gives the same answer, so it can be tested by comparing what it returns. More in “Project: a ticket-booking core in Gleam”, so the tests, a script and the form can all use it:
module Split exposing (Bill, Item, parsePaise, rupees, shares, total, validate)
{-| The core of the bill splitter: amounts in whole paise, tax and tip rounded
half up per item, and leftover paise given out in alphabetical order, so that
the shares always add up to the bill.
-}
type alias Item =
{ name : String
, paise : Int
, sharedBy : List String
}
type alias Bill =
{ diners : List String
, items : List Item
, taxPercent : Int
, tipPercent : Int
}
{-| A percentage of an amount, rounded half up: 5 % of 1450 paise is 73.
-}
percentOf : Int -> Int -> Int
percentOf percent paise =
(paise * percent + 50) // 100
lineTotal : Bill -> Item -> Int
lineTotal bill item =
item.paise + percentOf bill.taxPercent item.paise + percentOf bill.tipPercent item.paise
total : Bill -> Int
total bill =
List.sum (List.map (lineTotal bill) bill.items)
{-| One amount split among names: everyone gets the same whole number of paise,
and the paise left over go one each to the first names in alphabetical order.
-}
splitAmong : List String -> Int -> List ( String, Int )
splitAmong names amount =
let
sorted =
List.sort names
count =
List.length sorted
base =
amount // count
leftover =
amount - base * count
in
List.indexedMap
(\index name ->
if index < leftover then
( name, base + 1 )
else
( name, base )
)
sorted
{-| Each diner's share in paise, in alphabetical order, every diner listed.
-}
shares : Bill -> List ( String, Int )
shares bill =
let
parts =
List.concatMap (\item -> splitAmong item.sharedBy (lineTotal bill item)) bill.items
shareOf diner =
List.sum (List.map Tuple.second (List.filter (\( name, _ ) -> name == diner) parts))
in
List.map (\diner -> ( diner, shareOf diner )) (List.sort bill.diners)
{-| Every problem of a bill, in the order of the form, or the bill itself.
-}
validate : Bill -> Result (List String) Bill
validate bill =
let
diners =
if List.isEmpty bill.diners then
[ "add at least one diner" ]
else
List.filterMap identity
[ if List.any (\d -> String.trim d == "") bill.diners then
Just "every diner needs a name"
else
Nothing
, if List.length (unique bill.diners) < List.length bill.diners then
Just "two diners have the same name"
else
Nothing
]
items =
if List.isEmpty bill.items then
[ "add at least one item" ]
else
List.concat (List.indexedMap (itemProblems bill.diners) bill.items)
rates =
List.filterMap identity
[ if bill.taxPercent < 0 || bill.taxPercent > 28 then
Just "tax must be from 0 to 28 percent"
else
Nothing
, if bill.tipPercent < 0 || bill.tipPercent > 25 then
Just "tip must be from 0 to 25 percent"
else
Nothing
]
problems =
diners ++ items ++ rates
in
if List.isEmpty problems then
Ok bill
else
Err problems
itemProblems : List String -> Int -> Item -> List String
itemProblems diners index item =
let
label =
"item " ++ String.fromInt (index + 1) ++ " (" ++ item.name ++ ")"
in
List.filterMap identity
[ if item.paise <= 0 || item.paise > 10000000 then
Just (label ++ ": the price must be from 0.01 to 100000.00")
else
Nothing
, if List.isEmpty item.sharedBy then
Just (label ++ ": shared by nobody")
else
Nothing
, case List.filter (\name -> not (List.member name diners)) item.sharedBy of
[] ->
Nothing
strangers ->
Just (label ++ ": not a diner: " ++ String.join ", " strangers)
, if List.length (unique item.sharedBy) < List.length item.sharedBy then
Just (label ++ ": a diner is listed twice")
else
Nothing
]
unique : List String -> List String
unique names =
List.foldl
(\name kept ->
if List.member name kept then
kept
else
kept ++ [ name ]
)
[]
names
{-| A price typed in rupees, such as "145" or "145.50", in paise; Nothing for
anything else, such as "145.505", "-3" or "1e3".
-}
parsePaise : String -> Maybe Int
parsePaise text =
case String.split "." (String.trim text) of
[ whole ] ->
Maybe.map (\r -> r * 100) (digits whole)
[ whole, fraction ] ->
if String.length fraction >= 1 && String.length fraction <= 2 then
Maybe.map2 (\r p -> r * 100 + p) (digits whole) (digits (String.padRight 2 '0' fraction))
else
Nothing
_ ->
Nothing
digits : String -> Maybe Int
digits text =
if text /= "" && String.all Char.isDigit text then
String.toInt text
else
Nothing
{-| Whole paise of 0 or more as rupees with two digits after the dot.
-}
rupees : Int -> String
rupees paise =
"₹" ++ String.fromInt (paise // 100) ++ "." ++ String.padLeft 2 '0' (String.fromInt (modBy 100 paise)) splitAmong is where the shares are made to add up. It sorts the sharers, gives each amount // count, works out
the leftover as amount - base * count, which is always from 0 to count - 1, and adds one paisa to the first
leftover names. The parts of one item therefore add up to its line total exactly, and so the shares add up to the
bill. Dinner.elm is a worker that splits one fixed dinner and prints a summary:
port module Dinner exposing (main)
{-| One dinner for three, split with the Split module and printed as a summary
that could be copied into a chat.
-}
import Split exposing (Bill)
port out : String -> Cmd msg
dinner : Bill
dinner =
{ diners = [ "Ravi", "Asha", "Meera" ]
, items =
[ { name = "masala dosa", paise = 14500, sharedBy = [ "Asha", "Ravi" ] }
, { name = "filter coffee", paise = 4000, sharedBy = [ "Asha", "Meera", "Ravi" ] }
, { name = "idli", paise = 9000, sharedBy = [ "Meera" ] }
]
, taxPercent = 5
, tipPercent = 10
}
summary : Bill -> String
summary bill =
case Split.validate bill of
Err problems ->
String.join "\n" ("The bill has problems:" :: problems)
Ok good ->
String.join "\n"
(List.map (\( diner, paise ) -> String.padRight 8 ' ' diner ++ String.padLeft 9 ' ' (Split.rupees paise)) (Split.shares good)
++ [ String.padRight 8 ' ' "total" ++ String.padLeft 9 ' ' (Split.rupees (Split.total good))
, "tax " ++ String.fromInt good.taxPercent ++ " %, tip " ++ String.fromInt good.tipPercent ++ " %, both on each item's price"
]
)
main : Program () () Never
main =
Platform.worker
{ init = \_ -> ( (), out (summary dinner) )
, update = \_ model -> ( model, Cmd.none )
, subscriptions = \_ -> Sub.none
} The session below printed it: Asha 98.72, Meera 118.83 and Ravi 98.70 rupees, a total of 316.25 that is the sum of the three. Asha paid both odd paise, one from the masala dosa and one from the filter coffee, because she comes first in alphabetical order. The rule is simple and the same every time, but over many bills it always favours the same names. Weigh it against two other rules, with your own use of the splitter in mind:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Rule | Shares add up | Same answer each run | Fair over many items | Easy to check by hand |
|---|---|---|---|---|
| Alphabetical order Gives each odd paisa to: the first names, A to Z | Good every paisa goes to someone | Good the order of names never changes | Poor Asha got both odd paise of the dinner, and would get every one | Good sort the names, count down the leftover |
| Turns, item by item Gives each odd paisa to: the next sharer after the last | Good every paisa goes to someone | Good the same items in the same order give the same answer | Good over many items each sharer pays about the same | Mixed you must track where the last item stopped |
| The one who pays Gives each odd paisa to: the diner who settles the bill | Good every paisa goes to the payer | Good one name, chosen in the form | Mixed the payer covers up to n - 1 paise on each item shared by n | Good all odd paise on one line |
How the answers decide
- “Will the same group split many bills, so small biases add up?” Yes, every week: rules out Alphabetical order and adds a caveat to The one who pays. Alphabetical order gives every odd paisa to the same first names; over many bills the same person always pays more.
- “Must anyone at the table be able to check the split by hand?” Yes: adds a caveat to Turns, item by item. Turns depend on where the previous item stopped, which is harder to follow at the table than a sorted list.
In short: alphabetical order is easy to check and fine for one dinner; a group that splits every week should take turns, item by item; and giving the odd paise to whoever pays keeps the rest of the shares round.
Checkpoint
Check: A pure core whose shares add up
Tests pin the rules, and a fuzz test finds the smallest failure
SplitTest.elm holds eight tests. Seven state one rule each with a small bill, such as 1001 paise shared by three
giving 334, 334 and 333. The fifth is a fuzz test: A test that elm-test runs many times with generated inputs, such as random lists of prices, and that checks a rule which must hold for every input, such as shares adding up to the bill. The seed makes a run repeatable.: elm-test generates lists of prices and
numbers of sharers, and for every one checks that the shares add up to Split.total:
module SplitTest exposing (suite)
import Expect
import Fuzz
import Split exposing (Bill)
import Test exposing (Test, describe, fuzz2, test)
bill : List String -> List ( String, Int, List String ) -> Bill
bill diners items =
{ diners = diners
, items = List.map (\( name, paise, sharedBy ) -> { name = name, paise = paise, sharedBy = sharedBy }) items
, taxPercent = 0
, tipPercent = 0
}
suite : Test
suite =
describe "Split"
[ test "one diner pays the whole bill" <|
\_ ->
Split.shares { diners = [ "Asha" ], items = [ { name = "chai", paise = 1200, sharedBy = [ "Asha" ] } ], taxPercent = 5, tipPercent = 0 }
|> Expect.equal [ ( "Asha", 1260 ) ]
, test "an even split leaves nothing over" <|
\_ ->
Split.shares (bill [ "Asha", "Meera", "Ravi" ] [ ( "thali", 3000, [ "Asha", "Meera", "Ravi" ] ) ])
|> Expect.equal [ ( "Asha", 1000 ), ( "Meera", 1000 ), ( "Ravi", 1000 ) ]
, test "leftover paise go to the first names in alphabetical order" <|
\_ ->
Split.shares (bill [ "Ravi", "Asha", "Meera" ] [ ( "thali", 1001, [ "Ravi", "Meera", "Asha" ] ) ])
|> Expect.equal [ ( "Asha", 334 ), ( "Meera", 334 ), ( "Ravi", 333 ) ]
, test "tax and tip are each rounded half up, per item, on the price" <|
\_ ->
Split.total { diners = [ "Asha" ], items = [ { name = "samosa", paise = 1450, sharedBy = [ "Asha" ] } ], taxPercent = 5, tipPercent = 10 }
|> Expect.equal (1450 + 73 + 145)
, fuzz2 (Fuzz.list (Fuzz.intRange 1 100000)) (Fuzz.intRange 1 4) "the shares always add up to the bill" <|
\prices sharers ->
let
diners =
List.take sharers [ "Asha", "Meera", "Ravi", "Zoya" ]
fuzzed =
{ diners = diners
, items = List.map (\paise -> { name = "item", paise = paise, sharedBy = diners }) prices
, taxPercent = 5
, tipPercent = 10
}
in
Split.shares fuzzed
|> List.map Tuple.second
|> List.sum
|> Expect.equal (Split.total fuzzed)
, test "an item nobody shares is refused" <|
\_ ->
Split.validate (bill [ "Asha" ] [ ( "lassi", 6000, [] ) ])
|> Expect.equal (Err [ "item 1 (lassi): shared by nobody" ])
, test "every problem is reported at once" <|
\_ ->
Split.validate { diners = [ "Asha", "Asha" ], items = [ { name = "dosa", paise = 0, sharedBy = [ "Kabir" ] } ], taxPercent = 40, tipPercent = 0 }
|> Expect.equal
(Err
[ "two diners have the same name"
, "item 1 (dosa): the price must be from 0.01 to 100000.00"
, "item 1 (dosa): not a diner: Kabir"
, "tax must be from 0 to 28 percent"
]
)
, test "prices typed in rupees become paise" <|
\_ ->
List.map Split.parsePaise [ "145", "145.5", "145.50", "145.505", "-3", "1e3", "" ]
|> Expect.equal [ Just 14500, Just 14550, Just 14550, Nothing, Nothing, Nothing, Nothing ]
] The project session runs them with --seed 42, so that the generated inputs are the same in every run, then
builds and runs the dinner, then builds the app; the build steps come up again in the last section:
"""Tests the split core with elm-test, prints the scripted dinner, then builds the app with --optimize and minifies it
the way the compiler's notes on optimising describe, measuring each file, and last writes a page to open in a browser
(session.py plays it)."""
from session import cache, run
cache()
run("""
cd splitter
elm-test --seed 42
elm make src/Dinner.elm --optimize --output=build/dinner.js
node run.cjs
elm make src/Main.elm --optimize --output=build/app.js
wc -c < build/app.js
uglifyjs build/app.js --compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" | uglifyjs --mangle --output build/app.min.js
wc -c < build/app.min.js
gzip -9 < build/app.min.js | wc -c
elm make src/Main.elm --output=build/index.html
""") Output
$ cd splitter
$ elm-test --seed 42
Solving dependencies > Compiling > Starting tests
elm-test 0.19.3-0
-----------------
Running 8 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 42
TEST RUN PASSED
Duration: … ms
Passed: 8
Failed: 0
$ elm make src/Dinner.elm --optimize --output=build/dinner.js
Dependencies ready!
Success! Compiled 2 modules.
Dinner ───> build/dinner.js
$ node run.cjs
Asha ₹98.72
Meera ₹118.83
Ravi ₹98.70
total ₹316.25
tax 5 %, tip 10 %, both on each item's price
$ elm make src/Main.elm --optimize --output=build/app.js
Success! Compiled 1 module.
Main ───> build/app.js
$ wc -c < build/app.js
135770
$ uglifyjs build/app.js --compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" | uglifyjs --mangle --output build/app.min.js
$ wc -c < build/app.min.js
23942
$ gzip -9 < build/app.min.js | wc -c
9282
$ elm make src/Main.elm --output=build/index.html
Success!
Main ───> build/index.html
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 splitter_session.py
TEST RUN PASSED, eight of eight. A passing test proves little until you have seen it fail, so the next session
breaks one line of the core: sed turns if index < leftover then into if False then, so the leftover paise are
dropped instead of given out, and the same tests run again:
"""Changes one line of the split core, so that the leftover paise are dropped instead of given out, and runs the same
tests on it (session.py plays it). The change goes through sed into a new file, which then replaces Split.elm, so the
command works the same with the sed of macOS and of Linux."""
from session import cache, run
cache()
run("""
cd splitter
sed 's/if index < leftover then/if False then/' src/Split.elm > Split.changed
mv Split.changed src/Split.elm
grep -n "if False then" src/Split.elm
! elm-test --seed 42
""") Output
$ cd splitter
$ sed 's/if index < leftover then/if False then/' src/Split.elm > Split.changed
$ mv Split.changed src/Split.elm
$ grep -n "if False then" src/Split.elm
61: if False then
$ elm-test --seed 42
Solving dependencies > Compiling > Starting tests
elm-test 0.19.3-0
-----------------
Running 8 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 42
↓ SplitTest
↓ Split
✗ leftover paise go to the first names in alphabetical order
▼ ▼
[("Asha",333),("Meera",333),("Ravi",333)]
╷
│ Expect.equal
╵
[("Asha",334),("Meera",334),("Ravi",333)]
▲ ▲
↓ SplitTest
↓ Split
✗ the shares always add up to the bill
Given ([1],2)
0
╷
│ Expect.equal
╵
1
TEST RUN FAILED
Duration: … ms
Passed: 6
Failed: 2
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bug_session.py
Two tests failed, and both say what is wrong. The rule test shows the expected and the actual list with markers
under the two parts that differ, 333 against 334. The fuzz test reports Given ([1],2): after its first failure
elm-test kept trying smaller inputs, which is called shrinking: What elm-test does after a fuzz test fails: it tries smaller and simpler inputs until it finds a small one that still fails, such as one item of 1 paisa shared by 2, and reports that one., and the smallest case
that still fails is one item of 1 paisa shared by two, where the shares add up to 0 and the bill is 1. The run
ended with exit status 2, which is what a CI step checks.
Key idea
Pin each rule with a small test, and the one property that must always hold, here that the shares add up, with a fuzz test and a fixed seed; break the code once on purpose to see both kinds fail.
Checkpoint
Check: elm-test, fuzz tests and shrinking
The form is a Browser.element wrapped around the core
Main.elm is the app. Its model keeps what was typed, as text, because a half-typed price such as 145. is not yet
a mistake; its update only stores what changes; and its view turns the form into a Bill on every change with
toBill, which lists every price and rate that does not parse and, once all of them parse, every problem that
Split.validate finds:
module Main exposing (main)
{-| The bill splitter as a Browser.element: a form for diners, items, tax and
tip, checked on every change, and the shares, or every problem, below it.
-}
import Browser
import Html exposing (Html, button, div, fieldset, h2, input, label, legend, li, p, text, ul)
import Html.Attributes exposing (checked, for, id, type_, value)
import Html.Events exposing (onClick, onInput)
import Split exposing (Bill)
type alias Row =
{ name : String
, price : String
, sharedBy : List String
}
type alias Model =
{ diners : String
, rows : List Row
, tax : String
, tip : String
}
type Msg
= DinersChanged String
| NameChanged Int String
| PriceChanged Int String
| Toggled Int String
| AddRow
| RemoveRow Int
| TaxChanged String
| TipChanged String
init : () -> ( Model, Cmd Msg )
init _ =
( { diners = "Asha, Meera, Ravi"
, rows = [ { name = "masala dosa", price = "145", sharedBy = [ "Asha", "Ravi" ] } ]
, tax = "5"
, tip = "10"
}
, Cmd.none
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
( case msg of
DinersChanged text ->
{ model | diners = text }
NameChanged index text ->
{ model | rows = updateRow index (\row -> { row | name = text }) model.rows }
PriceChanged index text ->
{ model | rows = updateRow index (\row -> { row | price = text }) model.rows }
Toggled index diner ->
{ model | rows = updateRow index (toggle diner) model.rows }
AddRow ->
{ model | rows = model.rows ++ [ { name = "", price = "", sharedBy = [] } ] }
RemoveRow index ->
{ model | rows = List.take index model.rows ++ List.drop (index + 1) model.rows }
TaxChanged text ->
{ model | tax = text }
TipChanged text ->
{ model | tip = text }
, Cmd.none
)
updateRow : Int -> (Row -> Row) -> List Row -> List Row
updateRow index change rows =
List.indexedMap
(\i row ->
if i == index then
change row
else
row
)
rows
toggle : String -> Row -> Row
toggle diner row =
if List.member diner row.sharedBy then
{ row | sharedBy = List.filter (\d -> d /= diner) row.sharedBy }
else
{ row | sharedBy = row.sharedBy ++ [ diner ] }
dinersOf : Model -> List String
dinersOf model =
List.filter (\d -> d /= "") (List.map String.trim (String.split "," model.diners))
{-| The form as a Bill, or every problem with it: first what does not parse, then
what Split.validate finds.
-}
toBill : Model -> Result (List String) Bill
toBill model =
let
prices =
List.map (\row -> Split.parsePaise row.price) model.rows
priceProblems =
List.filterMap identity
(List.indexedMap
(\i ( row, parsed ) ->
case parsed of
Just _ ->
Nothing
Nothing ->
Just ("item " ++ String.fromInt (i + 1) ++ " (" ++ row.name ++ "): write the price in rupees, such as 145.50")
)
(List.map2 Tuple.pair model.rows prices)
)
rate label raw =
case String.toInt (String.trim raw) of
Just n ->
Ok n
Nothing ->
Err (label ++ " must be a whole number of percent")
in
case ( priceProblems, rate "tax" model.tax, rate "tip" model.tip ) of
( [], Ok tax, Ok tip ) ->
Split.validate
{ diners = dinersOf model
, items = List.map2 (\row paise -> { name = row.name, paise = Maybe.withDefault 0 paise, sharedBy = row.sharedBy }) model.rows prices
, taxPercent = tax
, tipPercent = tip
}
( problems, taxResult, tipResult ) ->
Err (problems ++ List.filterMap errorOf [ taxResult, tipResult ])
errorOf : Result String Int -> Maybe String
errorOf result =
case result of
Err problem ->
Just problem
Ok _ ->
Nothing
view : Model -> Html Msg
view model =
div []
[ label [ for "diners" ] [ text "Diners, separated by commas" ]
, input [ id "diners", value model.diners, onInput DinersChanged ] []
, div [] (List.indexedMap (viewRow (dinersOf model)) model.rows)
, button [ onClick AddRow ] [ text "Add an item" ]
, label [ for "tax" ] [ text "Tax, percent" ]
, input [ id "tax", value model.tax, onInput TaxChanged ] []
, label [ for "tip" ] [ text "Tip, percent" ]
, input [ id "tip", value model.tip, onInput TipChanged ] []
, h2 [] [ text "Shares" ]
, viewResult (toBill model)
]
viewRow : List String -> Int -> Row -> Html Msg
viewRow diners index row =
let
key =
"item-" ++ String.fromInt (index + 1)
in
fieldset []
[ legend [] [ text ("Item " ++ String.fromInt (index + 1)) ]
, label [ for (key ++ "-name") ] [ text "Name" ]
, input [ id (key ++ "-name"), value row.name, onInput (NameChanged index) ] []
, label [ for (key ++ "-price") ] [ text "Price in rupees" ]
, input [ id (key ++ "-price"), value row.price, onInput (PriceChanged index) ] []
, div []
(List.map
(\diner ->
label []
[ input [ type_ "checkbox", checked (List.member diner row.sharedBy), onClick (Toggled index diner) ] []
, text diner
]
)
diners
)
, button [ onClick (RemoveRow index) ] [ text ("Remove item " ++ String.fromInt (index + 1)) ]
]
viewResult : Result (List String) Bill -> Html Msg
viewResult result =
case result of
Ok bill ->
ul []
(List.map (\( diner, paise ) -> li [] [ text (diner ++ ": " ++ Split.rupees paise) ]) (Split.shares bill)
++ [ li [] [ text ("Total: " ++ Split.rupees (Split.total bill)) ] ]
)
Err problems ->
div []
[ p [] [ text "Fix these first:" ]
, ul [] (List.map (\problem -> li [] [ text problem ]) problems)
]
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
} Step through the project, from the core to the page:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The core. Split.elm holds every rule: shares, total, validate, parsePaise and rupees. It imports nothing but elm/core and knows nothing about pages or ports.
- The tests use the core. SplitTest.elm calls Split's functions with bills and compares what they return, so elm-test runs all 8 tests under Node.js, with no page at all.
- A script uses it. Dinner.elm, a Platform.worker, splits one fixed dinner and prints the summary through a port, so the rules can be read as a receipt.
- The form uses it. Main.elm is the Browser.element. Its update keeps what was typed, and its view turns the form into a Bill, asks Split for every problem or the shares, and draws the answer.
- One file to ship. elm make --optimize writes build/app.js, 135,770 bytes; uglifyjs, with the flags of the compiler's notes, writes build/app.min.js, 23,942 bytes, 9,282 after gzip.
- The page. A page loads app.min.js and calls Elm.Main.init with the node the form should take over. Everything a test checked is the same code the page runs.
This project checks the view with a rubric instead of tests: build build/index.html with the session’s last
command, elm make src/Main.elm --output=build/index.html, open it in a browser, and check the form against each
line.
| Check | What a good form does |
|---|---|
| Labels | every input has a label, the text fields through a for that matches their id and each checkbox inside its own label, as WCAG 2.2’s labels criterion asks |
| Problems | every message of toBill shows at once, not only the first |
| Amounts | shares in rupees with two digits, from whole paise, never a float on screen |
| Buttons | each says what it does, such as Remove item 2 |
| Keyboard | every control is reached with Tab, and each checkbox works with Space |
| Text | names and items appear through Html.text only, so typed markup stays text |
Parsing on every keystroke into the model
Storing parsePaise of each keystroke in the model, instead of the text, turns 145. into an error before the
person has typed 145.50, and loses what they typed when it does not parse. Keep the text, and parse it in
toBill when the view needs the numbers.
Checkpoint
Check: The form around the core
Optimise, minify, measure: the build you ship
The end of the project session follows the compiler’s notes on optimising. elm make --optimize wrote
build/app.js, 135,770 bytes. uglifyjs then compressed it with the flags those notes give and mangled the names,
in two passes as they ask, and wrote build/app.min.js, 23,942 bytes, 82 % smaller; gzip -9 brought that to 9,282
bytes, about what a server that compresses its files would send.
The flags are safe only because of what Elm guarantees. pure_funcs=[F2,…,A9] tells the minifier that Elm’s
helpers for calling functions have no side effects, so a call whose result is unused may be dropped, and
unsafe_comps and unsafe allow rewrites that ordinary JavaScript could break. Compiled Elm has no side effects
outside the runtime, which is what makes these flags safe for it and dangerous for hand-written code. The first
lesson’s worker saved under 1 % with --optimize alone; here the minifier: A tool that rewrites JavaScript into a smaller file that does the same thing, by shortening names and removing unused code and spaces. Elm's notes on optimising pass --optimize output through uglifyjs. saved most of
the file.
Measure in the build script
Print the size of the optimised, the minified and the gzipped file in every build, as the session does with
wc -c, and fail the build if the minified file grows past a budget you set, so that a new package cannot double
the download unnoticed.
Checkpoint
Check: Optimising and minifying the build
Practise: build the core in JavaScript too
Three steps in JavaScript, which the page runs in your browser, each the twin of a function of Split.elm and
tested with the same cases as SplitTest.elm: read a typed price as paise, split a bill so that the shares add up,
and report every problem of a bill at once. Writing the core twice, in two languages, is also how you would check a
port: run both on the same bills and compare. The dinner of Dinner.elm is among the tests of the second step:
Code lab · 3 steps
The splitter's core, in JavaScript
Parse prices in rupees into whole paise, split a bill whose shares always add up, and list every problem of a bill in the order of the form.
- Read a price typed in rupees as paise To do
- Split a bill so that the shares add up To do
- Report every problem of a bill at once To do
Exercise · Easy · JavaScript
Read a price typed in rupees as paise
The splitter's form takes prices as text in rupees, and Split.parsePaise turns them into whole paise before any arithmetic. Write the same function in JavaScript: parsePaise(text) in price.mjs returns the price in paise, or null for text that is not a price.
- Spaces around the text are ignored.
- A price is digits, then optionally a dot and one or two more digits:
"145"is 14500,"145.5"is 14550 and"145.50"is 14550. - Anything else is
null: three digits after the dot ("145.505"), a sign ("-3","+3"), an exponent ("1e3"), a dot with nothing on one side (".5","5."), commas ("1,450") and the empty string.
The starter code uses parseFloat, which reads "145.505", "1e3" and "12abc" without complaint and multiplies in floating point.
Starter code · price.mjs
/** A price typed in rupees, such as "145.50", in whole paise; null for text that is not a price. */
export function parsePaise(text) {
// Replace this: parseFloat accepts "1e3" and "12abc", and multiplying a float by 100 can give 14549.999999999998.
const value = parseFloat(text);
return Number.isNaN(value) ? null : Math.round(value * 100);
} The sample tests · price.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { parsePaise } from './price.mjs';
test('the prices of the elm-test test, as Split.parsePaise reads them', () => {
assert.equal(parsePaise('145'), 14500);
assert.equal(parsePaise('145.5'), 14550);
assert.equal(parsePaise('145.50'), 14550);
assert.equal(parsePaise('145.505'), null);
assert.equal(parsePaise('-3'), null);
assert.equal(parsePaise('1e3'), null);
assert.equal(parsePaise(''), null);
});
test('spaces around the text are ignored', () => {
assert.equal(parsePaise(' 40 '), 4000);
assert.equal(parsePaise(' 0.05'), 5);
});
test('text that only looks like a price', () => {
for (const text of ['+3', '.5', '5.', '1,450', '12abc', '12.3.4', 'abc', ' ']) {
assert.equal(parsePaise(text), null, `"${text}" is not a price`);
}
});
test('whole paise, never a float', () => {
assert.equal(parsePaise('0.29'), 29);
assert.equal(parsePaise('1.15'), 115);
assert.equal(parsePaise('100000.00'), 10000000);
}); A hint
Check the whole trimmed text against one regular expression, such as /^(\d+)(?:\.(\d{1,2}))?$/, before you convert anything. Then build the paise from the two groups as whole numbers: the rupees times 100, plus the paise part padded to two digits with padEnd(2, '0').
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 · Medium · JavaScript
Report every problem of a bill at once
Split.validate returns every problem of a bill, in the order of the form, so that the page can show them all at once. Write validate(bill) in validate.mjs with the same checks and the same messages; it returns an array of messages, empty when the bill is fine. bill is { diners, items: [{ name, paise, sharedBy }], taxPercent, tipPercent }.
- Diners: none at all gives
add at least one diner(and no other diner message); otherwise a name that is empty after trimming givesevery diner needs a name, and two equal names givetwo diners have the same name. - Items: none at all gives
add at least one item; otherwise, for each itemn(from 1) with namename, in this order:item n (name): the price must be from 0.01 to 100000.00whenpaiseis not from 1 to 10000000,item n (name): shared by nobody,item n (name): not a diner: Kabir, Zoyafor sharers who are not diners (in the order listed), anditem n (name): a diner is listed twice. - Rates:
tax must be from 0 to 28 percentandtip must be from 0 to 25 percent.
Each message appears at most once. The starter code stops at the first problem, the habit the form should not have.
Starter code · validate.mjs
/** Every problem of a bill, in the order of the form; [] when there is none. */
export function validate(bill) {
// Replace this: it returns only the first problem it finds.
if (bill.diners.length === 0) return ['add at least one diner'];
for (const [i, item] of bill.items.entries()) {
if (item.sharedBy.length === 0) return [`item ${i + 1} (${item.name}): shared by nobody`];
}
return [];
} The sample tests · validate.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { validate } from './validate.mjs';
const item = (name, paise, sharedBy) => ({ name, paise, sharedBy });
const good = { diners: ['Asha', 'Ravi'], items: [item('dosa', 14500, ['Asha', 'Ravi'])], taxPercent: 5, tipPercent: 10 };
test('a good bill has no problems', () => {
assert.deepEqual(validate(good), []);
});
test('the two cases of SplitTest.elm', () => {
assert.deepEqual(validate({ diners: ['Asha'], items: [item('lassi', 6000, [])], taxPercent: 0, tipPercent: 0 }), ['item 1 (lassi): shared by nobody']);
assert.deepEqual(validate({ diners: ['Asha', 'Asha'], items: [item('dosa', 0, ['Kabir'])], taxPercent: 40, tipPercent: 0 }), [
'two diners have the same name',
'item 1 (dosa): the price must be from 0.01 to 100000.00',
'item 1 (dosa): not a diner: Kabir',
'tax must be from 0 to 28 percent',
]);
});
test('no diners and no items', () => {
assert.deepEqual(validate({ diners: [], items: [], taxPercent: 0, tipPercent: 0 }), ['add at least one diner', 'add at least one item']);
});
test('names that are empty, and sharers listed twice', () => {
assert.deepEqual(validate({ ...good, diners: ['Asha', ' ', 'Ravi'] }), ['every diner needs a name']);
assert.deepEqual(validate({ ...good, items: [item('dosa', 14500, ['Asha', 'Asha'])] }), ['item 1 (dosa): a diner is listed twice']);
});
test('prices at and past the limits, and the item numbers', () => {
assert.deepEqual(validate({ ...good, items: [item('a', 1, ['Asha']), item('b', 10000000, ['Ravi'])] }), []);
assert.deepEqual(validate({ ...good, items: [item('a', 1, ['Asha']), item('b', 10000001, ['Ravi'])] }), ['item 2 (b): the price must be from 0.01 to 100000.00']);
assert.deepEqual(validate({ ...good, items: [item('c', -5, ['Zoya', 'Asha', 'Kabir'])] }), [
'item 1 (c): the price must be from 0.01 to 100000.00',
'item 1 (c): not a diner: Zoya, Kabir',
]);
});
test('the limits of the rates', () => {
assert.deepEqual(validate({ ...good, taxPercent: 28, tipPercent: 25 }), []);
assert.deepEqual(validate({ ...good, taxPercent: -1, tipPercent: 26 }), ['tax must be from 0 to 28 percent', 'tip must be from 0 to 25 percent']);
assert.deepEqual(validate({ ...good, taxPercent: 0, tipPercent: 0 }), []);
}); A hint
Collect messages into one array with push instead of returning early: one block for the diners, a loop over the items with forEach((item, i) => …) that checks the four item rules in order, and two checks for the rates. new Set(names).size < names.length tells you that a name appears twice.
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 the self-check on the five parts of the project, starting with the one you know least:
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): Why does the splitter work in whole paise instead of rupees with decimals?
Because binary floating point cannot hold most decimal fractions exactly: 0.1 + 0.2 printed 0.30000000000000004 in
the second lesson, and a sum of many such prices drifts. Whole paise are exact integers, so addition, the half-up
rounding of tax with (paise * percent + 50) // 100 and the split into equal parts are all exact, and the shares can
be proved to add up to the bill. Text in rupees is turned into paise once, at the edge, by parsePaise, which
refuses anything that is not a price, and paise become rupees again only for display, with rupees. The limit on
prices keeps every quotient far inside the range where Elm’s // is exact.
Two longer questions, one about fairness and one about a bill that does not match the restaurant’s:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
Users of your Elm bill splitter notice that the diner whose name comes first always pays the odd paise, a few paise every week. Change the rule so that it is fair over time without breaking the guarantee that the shares add up, and say how you would test it.
-
Fresher to senior
A diner says the splitter's total is 2 paise more than the restaurant's printed bill. The restaurant applies tax once to the subtotal, while the splitter rounds tax on each item. Which is right, and how do you make the app match the receipt without breaking the shares?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does the splitter round tax and tip without any floats?
Each is a percentage of an item's price in whole paise, rounded half up on its own with (paise * percent + 50) // 100, so 72.5 paise of tax becomes 73.
-
How do one item's parts always add up to its line total?
splitAmong gives each sorted sharer amount // count, then one more paisa to the first leftover names, where leftover = amount - base * count lies from 0 to count - 1.
-
Why did Asha pay both odd paise of the dinner, and what is the catch?
Leftover paise go to sharers in alphabetical order, and Asha comes first. The rule is easy to check, but over many bills it always favours the same names.
-
Why does the splitter cap prices at 100000.00 rupees?
To keep every quotient in the core far below 2,147,483,647, past which Elm's // wraps.
-
What does the splitter's fuzz test check, and why does it run with --seed 42?
That the shares add up to the bill for generated lists of prices and sharers. The fixed seed makes elm-test generate the same inputs in every run.
-
What is shrinking in elm-test?
After a fuzz failure, elm-test tries smaller inputs that still fail. The broken splitter came down to one item of 1 paisa shared by two: Given ([1],2).
-
Why does the form keep what was typed as text in its model?
A half-typed price such as 145. is not a mistake yet, and parsing early would lose what was typed. toBill parses when the view needs numbers and lists every problem at once.
-
Why are uglifyjs's pure_funcs and unsafe flags safe for compiled Elm, and what did they save?
Compiled Elm has no side effects outside the runtime, so dropping unused calls cannot change it. The app went from 135,770 bytes to 23,942 minified, 9,282 gzipped.
Key takeaways
- Write the rounding rules down before the code: whole paise, tax and tip each rounded half up on an item’s price, and a fixed rule for the paise left over after an equal split.
- Keep the rules in a pure module:
Split.elmgives every shareramount // countand one more paisa to the firstleftovernames, so the parts of each item, and so the shares, add up to the bill. - Pin each rule with a small test and the sum with a fuzz test on a fixed seed; breaking one line made two tests fail, and shrinking reduced the fuzz failure to one item of 1 paisa shared by two.
- Keep the form’s text in the model and parse it in the view’s
toBill, so that the problems show together as a list and nothing typed is lost; check the view with the rubric. - Ship
--optimizeoutput through a minifier with Elm’s flags: 135,770 bytes became 23,942, and 9,282 gzipped.
Terms in this lesson
- fuzz test
- A test that elm-test runs many times with generated inputs, such as random lists of prices, and that checks a rule which must hold for every input, such as shares adding up to the bill. The seed makes a run repeatable.
- minifier
- A tool that rewrites JavaScript into a smaller file that does the same thing, by shortening names and removing unused code and spaces. Elm's notes on optimising pass --optimize output through uglifyjs.
- shrinking
- What elm-test does after a fuzz test fails: it tries smaller and simpler inputs until it finds a small one that still fails, such as one item of 1 paisa shared by 2, and reports that one.
References
- elm/browser: Browser.element (Elm)
- elm/html: Html, Html.Attributes and Html.Events (Elm)
- elm/core: Basics, List and String (Elm)
- An Introduction to Elm: Forms (Elm)
- elm-explorations/test: Test, Expect and Fuzz (Elm)
- elm-test on npm (0.19.3-0) (npm)
- How to optimize Elm code (Elm)
- UglifyJS 3 (uglify-js on npm) (npm)
- WCAG 2.2: Labels or Instructions (3.3.2) (W3C)
- CGST Act, 2017, section 9: levy and collection (Central Board of Indirect Taxes and Customs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress