Language Atlas (mini-courses for more languages) Module 10 – Gleam on the BEAM and in JavaScript
Gleam for JavaScript and Python developers
Map JavaScript and Python habits to Gleam: custom types and case, Result and use instead of exceptions, pipes and labels, and integers that differ by target.
What you will learn
- Model data with custom types and case expressions, and let the compiler list every case a new variant breaks
- Handle expected failures with Result and use, and tell when let assert is the right tool
- Write pipelines with labelled arguments and inspect them with echo
- Predict integer division, rounding and parsing in Gleam, and compare them with Python and JavaScript
- Recognise code that builds for one target only and keep it out of shared modules
Before you start
What you’ll learn · Before you start
What you will learn
- Model data with custom types and case expressions, and let the compiler list every case a new variant breaks
- Handle expected failures with Result and use, and tell when let assert is the right tool
- Write pipelines with labelled arguments and inspect them with echo
- Predict integer division, rounding and parsing in Gleam, and compare them with Python and JavaScript
- Recognise code that builds for one target only and keep it out of shared modules
Before you start
On this page
A form check written in JavaScript accepted "4e1" as an age, because Number("4e1") is 40, and reported only the
first of three problems, because it throws. In Gleam the same check returns every problem as a value, and
int.parse("4e1") gives Error(Nil): Gleam has no null, no exceptions for expected failures and no implicit
conversions, so the habits that carry those bugs have nowhere to go.
Here you map what you already write in JavaScript or Python to Gleam: custom types and case instead of classes,
enums and null; Result and use instead of exceptions; pipelines with labelled arguments, read with echo; whole
numbers that divide, round and parse unlike both languages you know; and the line between code that runs on both
targets and code that needs the BEAM. Every program here ran on Erlang/OTP 29.1.1 and on Node.js 24.21.0.
Custom types and case replace classes, enums and null
Where JavaScript has a class hierarchy or an object with a kind field, and Python has subclasses or a match
over dataclasses, Gleam has one custom type: A Gleam type defined with pub type and one or more variants, each with its own fields, such as Shape with Circle, Rectangle and Square. A value of the type is always exactly one of its variants. with several variants, each with its own
fields. A Shape is a Circle, a Rectangle or a Square, and nothing else: there is no null shape and no
fourth kind that code forgets. A case expression takes a value apart by its variant:
import gleam/float
import gleam/io
import gleam/list
/// π, as close as a Float can hold it.
const pi = 3.141592653589793
/// A shape is one of three variants, each with its own fields.
pub type Shape {
Circle(radius: Float)
Rectangle(width: Float, height: Float)
Square(side: Float)
}
pub fn area(shape: Shape) -> Float {
case shape {
Circle(radius:) -> pi *. radius *. radius
Rectangle(width:, height:) -> width *. height
Square(side:) -> side *. side
}
}
pub fn name(shape: Shape) -> String {
case shape {
Circle(..) -> "circle"
Rectangle(width:, height:) if width == height -> "rectangle with equal sides"
Rectangle(..) -> "rectangle"
Square(..) -> "square"
}
}
pub fn main() -> Nil {
[
Circle(radius: 2.0),
Rectangle(width: 2.0, height: 3.5),
Rectangle(4.0, 4.0),
Square(side: 1.5),
]
|> list.each(fn(shape) {
io.println(name(shape) <> ": area " <> float.to_string(area(shape)))
})
} Circle(radius:) matches any circle and binds its field to radius. Rectangle(..) matches any rectangle and
ignores its fields. A guard, if width == height, adds a condition to a pattern. Step through the case that names a
Rectangle(4.0, 4.0), and then the one change that would break it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The value. name receives Rectangle(width: 4.0, height: 4.0). A case expression tries its patterns from the top and runs the first one that matches.
- Not a circle. Circle(..) matches any Circle, whatever its radius, and only a Circle. This value was built with Rectangle, so the next pattern is tried.
- A guard. Rectangle(width:, height:) matches and binds both fields, and the guard if width == height holds for 4.0 and 4.0, so this branch runs.
- Branches not tried. The two patterns below are never tried for this value. Rectangle(2.0, 3.5) would fail the guard and stop at Rectangle(..), the next line.
- One result. The case expression is a value: here the string rectangle with equal sides, which main printed with the area 16.0.
- A variant without a pattern. Add Triangle to Shape and keep the case as it is: no pattern would fit a Triangle. The compiler refuses to build and names the missing pattern, Triangle(base:, height:), for each case expression.
The session runs the module on both targets, then adds a fourth variant, Triangle, without touching the two case
expressions:
from session import run
# Custom types and case: shapes.gleam on both targets, then the compiler's answer when a fourth variant, Triangle,
# is added (triangle/shapes.gleam) without updating the two case expressions that read a Shape.
run(
"""
cd basics
gleam run -m shapes
gleam run -m shapes --target javascript
cp ../triangle/shapes.gleam src/shapes.gleam
!gleam build
"""
) Output
$ cd basics
$ gleam run -m shapes
Downloading packages
Downloaded 2 packages in …s
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running shapes.main
circle: area 12.566370614359172
rectangle: area 7.0
rectangle with equal sides: area 16.0
square: area 2.25
$ gleam run -m shapes --target javascript
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running shapes.main
circle: area 12.566370614359172
rectangle: area 7.0
rectangle with equal sides: area 16.0
square: area 2.25
$ cp ../triangle/shapes.gleam src/shapes.gleam
$ gleam build
Compiling basics
error: Inexhaustive patterns
┌─ src/shapes.gleam:17:3
│
17 │ ╭ case shape {
18 │ │ Circle(radius:) -> pi *. radius *. radius
19 │ │ Rectangle(width:, height:) -> width *. height
20 │ │ Square(side:) -> side *. side
21 │ │ }
│ ╰───^
This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.
The missing patterns are:
Triangle(base:, height:)
error: Inexhaustive patterns
┌─ src/shapes.gleam:25:3
│
25 │ ╭ case shape {
26 │ │ Circle(..) -> "circle"
27 │ │ Rectangle(width:, height:) if width == height -> "rectangle with equal sides"
28 │ │ Rectangle(..) -> "rectangle"
29 │ │ Square(..) -> "square"
30 │ │ }
│ ╰───^
This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.
The missing patterns are:
Triangle(base:, height:)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_session.py
Both targets printed the same four lines. With Triangle added, the build stopped with Inexhaustive patterns at
both case expressions, and each error lists Triangle(base:, height:) as the missing pattern. That is the compiler’s
exhaustiveness check: The compiler's check that the patterns of a case expression cover every possible value. In Gleam a case that misses a variant does not compile: the error, Inexhaustive patterns, lists the missing patterns.: a variant added to a type is a list of compile errors at
every case that must learn about it, not a TypeError in production. Where JavaScript returns undefined and
Python returns None, Gleam returns a Result (the standard library’s list.first does) or an Option from
gleam/option, and the type says so.
A catch-all pattern that hides the next variant
Ending a case with _ -> "unknown shape" silences the exhaustiveness check: when Triangle arrives, it is
quietly called unknown. Name every variant, as shapes.gleam does, so that the compiler can point at each case to
update.
Checkpoint
Check: Custom types and case
Expected failures are Result values, and use keeps the happy path flat
The language tour puts it plainly: Gleam doesn’t use exceptions, and computations that can succeed or fail return the
built-in Result: Gleam's built-in type for a computation that can fail: Ok(value) when it worked, Error(reason) when it did not. Result(Int, String) holds an Int or a String reason, and the caller must handle both., Ok(value) or Error(reason). parse_age returns
Result(Int, String), so every caller sees in the type that it can fail. parse_person needs two such steps, the
split at : and the age, and a use expression: Gleam syntax, use x <- f(a), that turns all the code below it into an anonymous function fn(x) { … } passed as the last argument of f. With result.try it carries on with an Ok value or returns the Error at once. with result.try writes each step on
one line:
import gleam/int
import gleam/list
import gleam/result
import gleam/string
pub type Person {
Person(name: String, age: Int)
}
/// An age from text, or the reason there is none.
pub fn parse_age(text: String) -> Result(Int, String) {
case int.parse(string.trim(text)) {
Ok(age) if age >= 0 && age <= 150 -> Ok(age)
Ok(age) -> Error("age out of range: " <> int.to_string(age))
Error(Nil) -> Error("not a number: " <> text)
}
}
/// "name:age" to a Person. Each `use` line returns the error at once, or
/// carries on with the value inside Ok.
pub fn parse_person(line: String) -> Result(Person, String) {
use #(name, age_text) <- result.try(
string.split_once(line, ":")
|> result.replace_error("no colon in: " <> line),
)
use age <- result.try(parse_age(age_text))
Ok(Person(name: string.trim(name), age:))
}
pub fn main() -> Nil {
["asha:31", "ravi:abc", "meera", "joe:200"]
|> list.each(fn(line) { echo parse_person(line) })
} The tour describes use exactly: all the code below a use line becomes an anonymous function, passed as the last
argument of the call on the right of <-. result.try calls that function with the value inside Ok, or returns
the Error at once. Follow parse_person("ravi:abc") through both lines:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Split the line. The first use line calls string.split_once on "ravi:abc". result.try receives its Result and the rest of the function, written below the use line, as a callback.
- Ok, carry on. split_once returns Ok(#("ravi", "abc")). result.try calls the callback with the pair, so name is "ravi" and age_text is "abc".
- Parse the age. The second use line calls parse_age("abc"). int.parse cannot read "abc" as a whole number and returns Error(Nil).
- An Error stops here. parse_age turns that into Error("not a number: abc"). result.try returns this Error at once, without calling the rest of the function.
- No exception, a value. parse_person returns the Error to its caller as an ordinary value, and its type, Result(Person, String), told the caller to expect one.
- The line that succeeds. For "asha:31" both calls return Ok, so the last line runs and builds Person(name: "asha", age: 31). Echo printed it as Ok(Person("asha", 31)) on Erlang.
crash.gleam shows the other tool, let assert: A Gleam assignment whose pattern may not match, such as let assert Ok(age) = parse_age(text). When it does not match, the program crashes on purpose with an optional message, so it is kept for values that cannot be wrong., for a value the program has already
checked and cannot use without: a pattern that may not match, and a deliberate crash with your message when it does
not. The session runs both modules on both targets:
import gleam/io
import results
pub fn main() -> Nil {
let assert Ok(first) = results.parse_age("31")
io.println("parsed 31")
let assert Ok(second) = results.parse_age("abc")
as "the form should have been checked first"
io.println("never printed")
echo first + second
Nil
} from session import run
# Results instead of exceptions: parse_person on four lines, on both targets (echo prints each value with its file
# and line), then let assert, which crashes on purpose when its pattern does not match.
run(
"""
cd basics
gleam run -m results
gleam run -m results --target javascript
!gleam run -m crash
!gleam run -m crash --target javascript
"""
) Output
$ cd basics
$ gleam run -m results
Downloading packages
Downloaded 2 packages in …s
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running results.main
src/results.gleam:32
Ok(Person("asha", 31))
src/results.gleam:32
Error("not a number: abc")
src/results.gleam:32
Error("no colon in: meera")
src/results.gleam:32
Error("age out of range: 200")
$ gleam run -m results --target javascript
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running results.main
src/results.gleam:32
Ok(Person(name: "asha", age: 31))
src/results.gleam:32
Error("not a number: abc")
src/results.gleam:32
Error("no colon in: meera")
src/results.gleam:32
Error("age out of range: 200")
$ gleam run -m crash
Compiled in …s
Running crash.main
parsed 31
runtime error: let assert
the form should have been checked first
unmatched value:
Error("not a number: abc")
stacktrace:
crash.main src/crash.gleam:7
[exit status 1]
$ gleam run -m crash --target javascript
Compiled in …s
Running crash.main
parsed 31
build/dev/javascript/prelude.mjs:1595
let error = new globalThis.Error(message);
^
Error: the form should have been checked first
at makeError (build/dev/javascript/prelude.mjs:1595:15)
at main (build/dev/javascript/basics/crash.mjs:41:11)
at build/dev/javascript/basics/gleam@@private_main_v1.18.1.mjs:2:1
at ModuleJob.run (node:internal/modules/esm/module_job:561:25)
at async node:internal/modules/esm/loader:647:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) {
gleam_error: 'let_assert',
file: 'src/crash.gleam',
module: 'crash',
line: 7,
function: 'main',
fn: 'main',
value: Error { '0': 'not a number: abc' },
start: 132,
end: 180,
pattern_start: 143,
pattern_end: 153
}
Node.js v24.21.0
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 results_session.py
The four lines gave one Ok and three different Errors, and nothing was thrown. Look closely at the Ok: on the
BEAM echo printed Ok(Person("asha", 31)), and on Node.js Ok(Person(name: "asha", age: 31)), so debug output can
differ between targets, while the values are equal. let assert stopped the program with exit status 1 on both: the
BEAM printed runtime error: let assert, the message and the unmatched value; Node.js threw an Error whose fields
carry the same message, file and line.
Key idea
A failure the caller should handle is an Error in the return type, and use … <- result.try(…) keeps a chain of
such steps flat; let assert is only for values that cannot be wrong, and it crashes with your message when they
are.
Checkpoint
Check: Result, use and let assert
Pipes, labels and echo read in the order the data flows
f(g(h(x))) reads inside out. A Gleam pipeline reads the way the data moves: x |> h |> g |> f, where each |>
passes the value on its left as the first argument of the call on its right. JavaScript has no pipe operator yet, and
Python has none, so this is the line where both groups slow down at first. pipes.gleam turns a messy list of names
into one line, and computes an order total with labelled argument: A function argument with a name callers write, such as discount: 10. A Gleam declaration gives each a label and an internal name, discount percent: Int, and labelled arguments may be passed in any order.:
import gleam/int
import gleam/io
import gleam/list
import gleam/string
/// `discount` and `delivery` are labels: a call names them, in any order.
pub fn total(
prices: List(Int),
discount percent: Int,
delivery fee: Int,
) -> Int {
let sum = int.sum(prices)
sum - sum * percent / 100 + fee
}
pub fn main() -> Nil {
" Meera, asha ,Ravi "
|> string.split(",")
|> list.map(string.trim)
|> echo
|> list.map(string.capitalise)
|> list.sort(string.compare)
|> string.join(with: " · ")
|> io.println
let a = total([450, 120, 30], discount: 10, delivery: 40)
let b = total([450, 120, 30], delivery: 40, discount: 10)
io.println("total: " <> int.to_string(a) <> " and " <> int.to_string(b))
} Step through the pipeline, with the value after each step:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Start with the text. The pipeline starts from one string with spaces around the commas. Each |> passes the value on its left as the first argument of the call on its right.
- Split. string.split(",") gives a list of three strings, still with their spaces: " Meera", " asha " and "Ravi ".
- Trim each. list.map(string.trim) calls trim on each name. string.trim is passed as a function, without calling it.
- Look, without stopping. echo prints the value it is given, after the file and line, here src/pipes.gleam:20, and returns it unchanged, so the pipeline goes on.
- Capitalise. list.map(string.capitalise) turns asha into Asha. The other two names already start with a capital letter.
- Sort. list.sort(string.compare) sorts the names. Gleam has no default ordering, so the comparison function is passed in.
- Join with a label. string.join(with: " · ") names its second argument by its label, with. io.println then printed Asha · Meera · Ravi.
echo in the middle of the pipeline printed src/pipes.gleam:20 and the list to standard error, then returned the
list, so nothing else changed. It is for debugging only: the changelog of Gleam 1.9, which added it, says the build
tool refuses to publish a package that still has echo in it. total declares discount percent and
delivery fee: the first word is the label callers use, the second the name inside the function, so total(prices, discount: 10, delivery: 40) and
total(prices, delivery: 40, discount: 10) are the same call. The session also runs form.gleam, the port of the
form check, and the module’s five tests on both targets:
import gleam/int
import gleam/list
import gleam/string
pub type Form {
Form(name: String, age: Int, email: String)
}
/// Every problem of the three fields, in field order, or the checked form.
pub fn validate(
name name: String,
age age: String,
email email: String,
) -> Result(Form, List(String)) {
let name = string.trim(name)
let checked_name = case name {
"" -> Error("name is required")
_ -> Ok(name)
}
let checked_age = case int.parse(string.trim(age)) {
Error(Nil) -> Error("age must be a whole number")
Ok(years) if years < 18 || years > 120 ->
Error("age must be between 18 and 120")
Ok(years) -> Ok(years)
}
let email = string.trim(email)
let checked_email = case string.split(email, "@") {
[user, domain] if user != "" && domain != "" -> Ok(email)
_ -> Error("email needs one @ with text on both sides")
}
case checked_name, checked_age, checked_email {
Ok(name), Ok(age), Ok(email) -> Ok(Form(name:, age:, email:))
_, _, _ ->
Error(
list.filter_map(
[problem(checked_name), problem(checked_age), problem(checked_email)],
fn(found) { found },
),
)
}
}
fn problem(checked: Result(a, String)) -> Result(String, Nil) {
case checked {
Ok(_) -> Error(Nil)
Error(message) -> Ok(message)
}
}
pub fn main() -> Nil {
let _ = echo validate(name: " Asha ", age: "31", email: "asha@example.com")
let _ = echo validate(name: "", age: "4e1", email: "ravi.example.com")
let _ = echo validate(name: "Meera", age: "12", email: "meera@example.com")
Nil
} from session import run
# Pipes and labelled arguments, a form check that collects every problem, and the module's tests on both targets.
run(
"""
cd basics
gleam run -m pipes
gleam run -m form
gleam test
gleam test --target javascript
"""
) Output
$ cd basics
$ gleam run -m pipes
Downloading packages
Downloaded 2 packages in …s
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running pipes.main
src/pipes.gleam:20
["Meera", "asha", "Ravi"]
Asha · Meera · Ravi
total: 580 and 580
$ gleam run -m form
Compiled in …s
Running form.main
src/form.gleam:51
Ok(Form("Asha", 31, "asha@example.com"))
src/form.gleam:52
Error(["name is required", "age must be a whole number", "email needs one @ with text on both sides"])
src/form.gleam:53
Error(["age must be between 18 and 120"])
$ gleam test
Compiled in …s
Running basics_test.main
.....
5 passed, no failures
$ gleam test --target javascript
Compiling gleam_stdlib
Compiling gleeunit
Compiling basics
Compiled in …s
Running basics_test.main
.....
5 passed, no failures
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipes_session.py
The pipeline printed Asha · Meera · Ravi, both calls of total gave 580, and validate returned the three
problems of the second form in one Error, where the JavaScript version threw after the first. All five tests
passed on Erlang and on JavaScript.
Fail fast or collect
use with result.try stops at the first Error, which suits steps that depend on each other, such as parsing an
age only after the split worked. A form whose fields are independent checks each one and collects every message,
as validate does with one case over its three results.
Checkpoint
Check: Pipes, labelled arguments and echo
Whole numbers: Gleam is neither Python nor JavaScript
Integer arithmetic is where ported code goes wrong without a single error. Gleam’s / on two Ints drops the
fraction, rounding towards zero, which is truncating division: Integer division that drops the fraction, rounding towards zero, so -7 divided by 2 gives -3. C, Objective-C and Visual Basic's \ divide this way, and their remainder takes the dividend's sign. More in “A one-week plan for learning any language”; Python’s //
rounds down instead, which is floor division: Integer division that rounds the quotient down, towards minus infinity, so -7 divided by 2 gives -4. Tcl's / on integers and Python's // divide this way, and their remainder takes the divisor's sign. More in “A one-week plan for learning any language”. Gleam defines division by zero as zero,
where Python raises an error and JavaScript gives Infinity. The same table was run three times: in Gleam on both
targets, in Python 3.14.8 and in Node.js 24.21.0:
import gleam/float
import gleam/int
import gleam/io
import gleam/string
fn show(expression: String, value: String) -> Nil {
io.println(string.pad_end(expression, 26, " ") <> value)
}
pub fn main() -> Nil {
show("-7 / 2", int.to_string(-7 / 2))
show("-7 % 2", int.to_string(-7 % 2))
show("int.floor_divide(-7, 2)", string.inspect(int.floor_divide(-7, 2)))
show("int.modulo(-7, 2)", string.inspect(int.modulo(-7, 2)))
show("7 / 0", int.to_string(7 / 0))
show("int.divide(7, 0)", string.inspect(int.divide(7, 0)))
show("float.round(-2.5)", int.to_string(float.round(-2.5)))
show("float.round(2.5)", int.to_string(float.round(2.5)))
show("9_007_199_254_740_993", int.to_string(9_007_199_254_740_993))
show("9_007_199_254_740_992 + 1", int.to_string(9_007_199_254_740_992 + 1))
show("int.parse(\"4e1\")", string.inspect(int.parse("4e1")))
show("int.parse(\"1_000\")", string.inspect(int.parse("1_000")))
show("string.inspect(1.0)", string.inspect(1.0))
} from session import run
# Integer arithmetic and the size of an Int, on the BEAM and on Node.js. The JavaScript build warns about the two
# literals that a 64-bit float cannot hold exactly.
run(
"""
cd numbers
gleam run
gleam run --target javascript
"""
) Output
$ cd numbers
$ gleam run
Downloading packages
Downloaded 2 packages in …s
Compiling gleam_stdlib
Compiling gleeunit
Compiling numbers
Compiled in …s
Running numbers.main
-7 / 2 -3
-7 % 2 -1
int.floor_divide(-7, 2) Ok(-4)
int.modulo(-7, 2) Ok(1)
7 / 0 0
int.divide(7, 0) Error(Nil)
float.round(-2.5) -3
float.round(2.5) 3
9_007_199_254_740_993 9007199254740993
9_007_199_254_740_992 + 1 9007199254740993
int.parse("4e1") Error(Nil)
int.parse("1_000") Error(Nil)
string.inspect(1.0) 1.0
$ gleam run --target javascript
Compiling gleam_stdlib
Compiling gleeunit
Compiling numbers
warning: Int is outside JavaScript's safe integer range
┌─ src/numbers.gleam:19:47
│
19 │ show("9_007_199_254_740_993", int.to_string(9_007_199_254_740_993))
│ ^^^^^^^^^^^^^^^^^^^^^ This is not a safe integer value on JavaScript
This integer value is too large to be represented accurately by
JavaScript's number type. To avoid this warning integer values must be in
the range -(2^53 - 1) - (2^53 - 1).
See JavaScript's Number.MAX_SAFE_INTEGER and Number.MIN_SAFE_INTEGER
properties for more information.
warning: Int is outside JavaScript's safe integer range
┌─ src/numbers.gleam:20:51
│
20 │ show("9_007_199_254_740_992 + 1", int.to_string(9_007_199_254_740_992 + 1))
│ ^^^^^^^^^^^^^^^^^^^^^ This is not a safe integer value on JavaScript
This integer value is too large to be represented accurately by
JavaScript's number type. To avoid this warning integer values must be in
the range -(2^53 - 1) - (2^53 - 1).
See JavaScript's Number.MAX_SAFE_INTEGER and Number.MIN_SAFE_INTEGER
properties for more information.
Compiled in …s
Running numbers.main
-7 / 2 -3
-7 % 2 -1
int.floor_divide(-7, 2) Ok(-4)
int.modulo(-7, 2) Ok(1)
7 / 0 0
int.divide(7, 0) Error(Nil)
float.round(-2.5) -3
float.round(2.5) 3
9_007_199_254_740_993 9007199254740992
9_007_199_254_740_992 + 1 9007199254740992
int.parse("4e1") Error(Nil)
int.parse("1_000") Error(Nil)
string.inspect(1.0) 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 numbers_session.py
compare/compare.py
# The same arithmetic and parsing in Python 3.14, for the table of the lesson.
print("-7 // 2 ", -7 // 2)
print("-7 % 2 ", -7 % 2)
print("round(-2.5) ", round(-2.5))
print("round(2.5) ", round(2.5))
print("2**53 + 1 ", 2**53 + 1)
try:
print(7 // 0)
except ZeroDivisionError as error:
print("7 // 0 ", "ZeroDivisionError:", error)
for text in [" 42", "4e1", "0x20", "1_000", ""]:
try:
value = int(text)
except ValueError:
value = "ValueError"
print(f"int({text!r})".ljust(14), value) Output
-7 // 2 -4
-7 % 2 1
round(-2.5) -2
round(2.5) 2
2**53 + 1 9007199254740993
7 // 0 ZeroDivisionError: division by zero
int(' 42') 42
int('4e1') ValueError
int('0x20') ValueError
int('1_000') 1000
int('') ValueError
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare.py
compare/compare.mjs
// The same arithmetic and parsing in JavaScript (Node.js 24), for the table of the lesson.
console.log("Math.trunc(-7 / 2)", Math.trunc(-7 / 2));
console.log("-7 % 2 ", -7 % 2);
console.log("Math.round(-2.5) ", Math.round(-2.5));
console.log("Math.round(2.5) ", Math.round(2.5));
console.log("2 ** 53 + 1 ", 2 ** 53 + 1);
console.log("7 / 0 ", 7 / 0);
for (const text of [" 42", "4e1", "0x20", "1_000", ""]) {
console.log(`Number(${JSON.stringify(text)})`.padEnd(18), Number(text));
} Output
Math.trunc(-7 / 2) -3
-7 % 2 -1
Math.round(-2.5) -2
Math.round(2.5) 3
2 ** 53 + 1 9007199254740992
7 / 0 Infinity
Number(" 42") 42
Number("4e1") 40
Number("0x20") 32
Number("1_000") NaN
Number("") 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compare.mjs
| Expression | Gleam, both targets | Python 3.14.8 | JavaScript, Node.js 24.21.0 |
|---|---|---|---|
| −7 divided by 2 | -7 / 2 is −3 |
-7 // 2 is −4 |
Math.trunc(-7 / 2) is −3 |
| −7 remainder 2 | -7 % 2 is −1 |
-7 % 2 is 1 |
-7 % 2 is −1 |
| 7 divided by 0 | 7 / 0 is 0; int.divide(7, 0) is Error(Nil) |
ZeroDivisionError |
Infinity |
| −2.5 rounded | float.round(-2.5) is −3 |
round(-2.5) is −2 |
Math.round(-2.5) is −2 |
| 2^53 + 1 | 9007199254740993 on the BEAM, 9007199254740992 on Node.js | 9007199254740993 | 9007199254740992 |
"4e1" read as a whole number |
int.parse gives Error(Nil) |
int raises ValueError |
Number gives 40 |
"1_000" read as a whole number |
int.parse gives Error(Nil) |
int gives 1000 |
Number gives NaN |
Python’s floor division is int.floor_divide(-7, 2) in Gleam, Ok(-4), and Python’s % is int.modulo, Ok(1);
both return a Result because the divisor may be zero. float.round rounds a half away from zero on both targets,
unlike Python’s round, which rounds to even, and JavaScript’s Math.round, which rounds a half up. The only row
where Gleam’s two targets disagree is 2^53 + 1: the tour says an Int on JavaScript is a 64-bit float, so past
Number.MAX_SAFE_INTEGER, 2^53 − 1, it loses whole units, and the compiler warned about both literals in the
JavaScript build. Note also the last line of the session: string.inspect(1.0) printed 1.0 on the BEAM and 1 on
Node.js.
Porting Python's // as Gleam's /
(total - paid) // 2 in Python and { total - paid } / 2 in Gleam agree for positive numbers and differ by one
for a negative one that does not divide evenly: −4 against −3 for −7. Use int.floor_divide when the old code
floored, and add a test with a negative odd value.
Checkpoint
Check: Whole numbers compared with Python and JavaScript
Code that needs the BEAM does not build for JavaScript
Most of the standard library has an implementation for each target, so basics passed its tests on both. Some
packages are written for one target only: gleam_erlang, which provides process (BEAM): A lightweight unit of execution on the BEAM, with its own memory and its own mailbox. Processes share nothing and work together by sending each other messages; in Gleam, process.spawn from gleam_erlang starts one. More in “Gleam processes, actors and FFI” and
the rest of Erlang’s runtime, has externals in Erlang and none in JavaScript. erlang_only.gleam spawns a process
that sends a message back:
import gleam/erlang/process
import gleam/io
pub fn main() -> Nil {
let subject = process.new_subject()
process.spawn(fn() { process.send(subject, "hello from another process") })
let assert Ok(message) = process.receive(subject, within: 1000)
io.println(message)
} from session import run
# A program that uses gleam/erlang/process, from the Erlang-only package gleam_erlang, on both targets. The
# JavaScript build also prints warnings about gleam_erlang's own code, so its output goes to build.log; the session
# counts the warnings, those of them that point into gleam_erlang's source and the Unsupported target errors, and
# shows the first error.
run(
"""
cd erlang_only
gleam run
!gleam run --target javascript > build.log 2>&1
grep -c '^warning' build.log
grep -A 1 '^warning' build.log | grep -c 'build/packages/gleam_erlang/'
grep -c '^error: Unsupported target' build.log
grep -m 1 -A 9 '^error' build.log
"""
) Output
$ cd erlang_only
$ gleam run
Downloading packages
Downloaded 3 packages in …s
Compiling gleam_stdlib
Compiling gleam_erlang
Compiling gleeunit
Compiling erlang_only
Compiled in …s
Running erlang_only.main
hello from another process
$ gleam run --target javascript > build.log 2>&1
[exit status 1]
$ grep -c '^warning' build.log
6
$ grep -A 1 '^warning' build.log | grep -c 'build/packages/gleam_erlang/'
6
$ grep -c '^error: Unsupported target' build.log
4
$ grep -m 1 -A 9 '^error' build.log
error: Unsupported target
┌─ src/erlang_only.gleam:5:25
│
5 │ let subject = process.new_subject()
│ ^^^^^^^^^^^
This value is not available as it is defined using externals, and there is
no implementation for the JavaScript target.
Hint: Did you mean to build for a different target?
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 target_session.py
On the BEAM it printed hello from another process. For JavaScript the build failed: it printed six warnings, all
six about gleam_erlang’s own code, and four Unsupported target errors, one at each use of a process function. The
first error, shown in full, says the value “is defined using externals” and has no implementation for the
JavaScript target. A build compiles every module in src for its target, so one Erlang-only call in any of them
stops the JavaScript build of the whole project. Step through which modules of this lesson build where:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Shared modules. shapes, results, pipes and form import only gleam_stdlib, which has an implementation for both targets.
- Both builds pass. gleam test and gleam test --target javascript both ran the five tests of basics and passed.
- An Erlang-only import. erlang_only.gleam imports gleam/erlang/process from gleam_erlang, whose functions are externals written in Erlang only.
- On the BEAM. The Erlang build spawned a process that sent hello from another process back to main.
- On JavaScript. The JavaScript build stopped with four Unsupported target errors, one for each use of a process function, after six warnings from gleam_erlang's own code.
- The fix is the layout. Keep code that needs the BEAM in modules that only the Erlang build uses, or give a function an external for each target, as the next lesson shows.
So a package that must run on both targets keeps its target-specific code at the edges: shared modules import only
what builds everywhere, such as gleam_stdlib, and code that needs processes lives in modules only the Erlang build
uses. When one function must exist on both, it can name an external implementation for each target, which the next
lesson writes.
Let the type checker see your input
Values from outside, such as a form, a file or a request, arrive as text. Turn them into typed values once, at
the edge, with functions that return Result, as validate does, and keep let assert away from them: a crafted
input then gets an error message instead of crashing the process that read it.
Checkpoint
Check: Code that builds for one target only
Practise: port a form check that throws
form_before.mjs is the JavaScript that form.gleam replaced. Run in its own file, it shows both of its bugs: the
second form reported only name is required, and the third was accepted with an age of 40, read from "4e1":
// A form check as JavaScript code often writes it: it throws at the first problem.
function validate(name, ageText, email) {
if (name.trim() === "") throw new Error("name is required");
const age = Number(ageText);
if (!Number.isInteger(age)) throw new Error("age must be a whole number");
if (age < 18 || age > 120) throw new Error("age must be between 18 and 120");
const parts = email.trim().split("@");
if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
throw new Error("email needs one @ with text on both sides");
}
return { name: name.trim(), age, email: email.trim() };
}
for (const fields of [[" Asha ", "31", "asha@example.com"], ["", "4e1", "ravi.example.com"], ["Ravi", "4e1", "ravi@example.com"]]) {
try {
console.log(validate(...fields));
} catch (error) {
console.log("Error:", error.message);
}
} Output
{ name: 'Asha', age: 31, email: 'asha@example.com' }
Error: name is required
{ name: 'Ravi', age: 40, email: 'ravi@example.com' }
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node form_before.mjs
The exercise ports form.gleam’s rules to Python, so that its sample tests run in your browser: return every problem
in field order, and read the age the way int.parse does, which neither JavaScript’s Number nor Python’s int
does on its own:
Exercise · Easy · Python
Check a form the way form.gleam does
form_before.mjs throws at the first problem, and its Number(ageText) reads "4e1" as 40. Port the rules of form.gleam to Python instead: write validate(name, age, email) in form.py, returning ("ok", form) with form a dict of name, age and email, or ("error", problems) with every problem, in field order. It mirrors Gleam's Result(Form, List(String)).
name, trimmed, must not be empty:"name is required".age, trimmed, must be a whole number written with digits only, an optional+or-first, the wayint.parsereads it: otherwise"age must be a whole number". A number below 18 or above 120 gives"age must be between 18 and 120".email, trimmed, must split at@into exactly two parts, neither empty:"email needs one @ with text on both sides".
The checked form holds the trimmed name and email and the age as an int. Do not use Python's int() on its own: it accepts " 42", "1_000" and other text that int.parse refuses.
Starter code · form.py
def validate(name, age, email):
"""("ok", {"name", "age", "email"}) or ("error", [every problem, in field order])."""
# Replace this with your code: it stops at the first problem, as form_before.mjs does, and trusts int().
if name.strip() == "":
return ("error", ["name is required"])
return ("ok", {"name": name.strip(), "age": int(age), "email": email.strip()}) The sample tests · test_form.py
from form import validate
def test_a_good_form_is_trimmed():
"""the first call of form.main: Ok(Form("Asha", 31, "asha@example.com"))"""
assert validate(" Asha ", "31", "asha@example.com") == ("ok", {"name": "Asha", "age": 31, "email": "asha@example.com"})
def test_every_problem_in_field_order():
"""the second call of form.main: three problems, not only the first"""
assert validate("", "4e1", "ravi.example.com") == (
"error",
["name is required", "age must be a whole number", "email needs one @ with text on both sides"],
)
def test_an_age_out_of_range():
"""the third call of form.main"""
assert validate("Meera", "12", "meera@example.com") == ("error", ["age must be between 18 and 120"])
def test_text_that_javascript_reads_as_a_number():
"""Number("0x20") is 32 in JavaScript; int.parse refuses it"""
assert validate("Ravi", "0x20", "ravi@example.com") == ("error", ["age must be a whole number"])
def test_text_that_python_reads_as_a_number():
"""int("1_000") is 1000 in Python; int.parse refuses it"""
assert validate("Ravi", "1_000", "ravi@example.com") == ("error", ["age must be a whole number"])
def test_two_at_signs():
"""split at @ gives three parts"""
assert validate("Joe", "40", "joe@@example.com") == ("error", ["email needs one @ with text on both sides"]) A hint
Check each field into its own variable first, either a value or a message, then decide: no messages gives ("ok", …), otherwise ("error", messages) in the order name, age, email. For the age, a regular expression such as ^[+-]?[0-9]+$ on the trimmed text accepts exactly what int.parse accepts.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 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.
The self-check below asks first about the weakest of your five ideas, from custom types to target-only code, and explains each answer the moment 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): How does Gleam handle an operation that can fail, without exceptions?
It returns a Result: Ok(value) when it worked and Error(reason) when it did not, and the type, such as
Result(Int, String), tells every caller to handle both. int.parse("abc") returns Error(Nil) instead of throwing.
The caller takes it apart with case, or chains steps with use x <- result.try(step), which carries on with the
value inside Ok and returns the first Error at once; in the lesson, parse_person("ravi:abc") returned
Error("not a number: abc") that way. For a value that cannot be wrong, let assert Ok(x) = … takes it out and
crashes with a message if it is wrong after all; that is for programmer errors, not for user input. Gleam’s panic
and the BEAM’s crashes still exist, but they are for bugs, not for control flow.
Two longer questions, one about a port from Python and one about a shared module:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A Python service splits a bill two ways with share = (total - discount) // 2, and the port to Gleam reads { total - discount } / 2. Every test passes, yet a refund of -7 now gives -3 each where Python gave -4. Explain the difference, fix the port, and say how you would have caught it.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
A fourth variant, Triangle, is added to a Gleam type. What happens to the case expressions over it?
Every one that does not handle Triangle stops the build with Inexhaustive patterns, listing the missing pattern. A catch-all _ branch would hide it instead, so name every variant.
-
How does a Gleam function say that it can fail?
It returns the built-in Result: Ok(value) or Error(reason), as in Result(Int, String), and every caller must handle both. Gleam uses no exceptions for expected failures.
-
What does use age <- result.try(parse_age(text)) do with the lines below it?
They become a function that result.try calls with the value inside Ok. An Error is returned at once, so a chain of failing steps stays flat.
-
When is let assert the right tool in Gleam?
For a value the code has already checked and cannot work without: it crashes with your message if the pattern does not match. User input should get an Error instead.
-
What does |> do, and what do labelled arguments add to a Gleam call?
|> passes the value on its left as the first argument of the call on its right, so data reads left to right. Labels such as discount: and delivery: let a call give its arguments in any order.
-
What are -7 / 2, -7 % 2 and 7 / 0 in Gleam?
−3, −1 and 0: / truncates, and division by zero is defined as zero. int.floor_divide(-7, 2) gives Ok(-4) and int.modulo(-7, 2) gives Ok(1), Python's results, as Results.
-
Where do Gleam's two targets give different whole numbers?
Beyond 2^53: on the BEAM an Int has no size limit, so 2^53 + 1 is 9007199254740993, while on JavaScript an Int is a 64-bit float and gives 9007199254740992.
-
Why does one call into gleam_erlang stop the JavaScript build of a whole project?
gleam_erlang's functions are externals implemented in Erlang only, so each use is an Unsupported target error, and a build compiles every module in src. Shared modules import only what builds for both.
Key takeaways
- A custom type lists every variant a value can be, and
casetakes it apart; adding a variant turns every case that must handle it into a compile error, asTriangledid twice. - Expected failures are
Resultvalues;use … <- result.try(…)stops at the firstError, acaseover several results collects them all, andlet assertcrashes on purpose for values that cannot be wrong. |>passes the value on its left as the first argument, labels make calls readable in any order, andechoprints a value with its file and line, in a format that can differ by target.- Gleam’s
/truncates and7 / 0is 0,int.floor_divideandint.modulogive Python’s results,float.roundrounds halves away from zero, and integers past 2^53 differ between the BEAM and JavaScript. - A call into an Erlang-only package such as
gleam_erlangstops the JavaScript build withUnsupported target, so shared modules import only what builds for both targets.
Terms in this lesson
- custom type
- A Gleam type defined with pub type and one or more variants, each with its own fields, such as Shape with Circle, Rectangle and Square. A value of the type is always exactly one of its variants.
- exhaustiveness check
- The compiler's check that the patterns of a case expression cover every possible value. In Gleam a case that misses a variant does not compile: the error, Inexhaustive patterns, lists the missing patterns.
- labelled argument
- A function argument with a name callers write, such as discount: 10. A Gleam declaration gives each a label and an internal name, discount percent: Int, and labelled arguments may be passed in any order.
- let assert
- A Gleam assignment whose pattern may not match, such as let assert Ok(age) = parse_age(text). When it does not match, the program crashes on purpose with an optional message, so it is kept for values that cannot be wrong.
- Result
- Gleam's built-in type for a computation that can fail: Ok(value) when it worked, Error(reason) when it did not. Result(Int, String) holds an Int or a String reason, and the caller must handle both.
- use expression
- Gleam syntax, use x <- f(a), that turns all the code below it into an anonymous function fn(x) { … } passed as the last argument of f. With result.try it carries on with an Ok value or returns the Error at once.
References
- The Gleam language tour (every chapter on one page) (Gleam)
- gleam_stdlib: gleam/int (Gleam)
- gleam_stdlib: gleam/float (Gleam)
- gleam_stdlib: gleam/result (Gleam)
- Gleam for Python users (Gleam)
- Gleam for Elixir users (Gleam)
- Writing Gleam: developing Gleam projects (Gleam)
- Gleam v1.9 changelog (the echo keyword) (Gleam)
- gleam_erlang: gleam/erlang/process (Gleam)
- Python 3 documentation: numeric types (floor division, modulo, round) (Python Software Foundation)
- MDN: Number.MAX_SAFE_INTEGER (Mozilla)
- MDN: Math.round() (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress