Modules, Imports, and Testing
- Gleam modules map one-to-one to source files: the file path determines the module name
- Opaque types expose a type name without revealing its internal representation
- The
gleeunittest framework runs tests from files in thetest/directory
Modules and Files
- Every Gleam source file is a module
- The module name comes from the file path relative to
src/src/myapp/utils.gleambecomes the modulemyapp/utils
import myapp/utilsmakes the module'sfuncavailable asutils.funcimport myapp/utils.{my_fn}importsmy_fnunqualified so you can call it without a prefix- Standard library modules follow the same convention:
import gleam/list,import gleam/dict,import gleam/string- Circular imports are not allowed
This Lesson
src/modules/types.gleamcontains shared type definitionssrc/modules/logic.gleamcontains pure functions over those typessrc/modules.gleamis the entry point that wires them together
Public and Private Definitions
- Every function and type in Gleam is private by default
- Adding
pubmakes it accessible from other modules
pub type Status {
Active
Done
}
pub type Todo {
Todo(title: String, status: Status)
}
pub type Statusexports the type and its constructors (Active,Done)- Other modules can create
ActiveorDonevalues
- Other modules can create
pub type Todoexports the record type and its constructor- Other modules can write
Todo(title: "...", status: Active)
- Other modules can write
- Omit
puband the type is invisible outside the file
pub fn display_status(status: Status) -> String {
case status {
Active -> "[ ]"
Done -> "[x]"
}
}
- Use private functions for implementation details that callers should not depend on directly
- E.g., have a public entry point and a private function to do the recursion
Opaque Types
- An opaque type makes the type name public but hides its constructors
- Other modules can hold a value of the type but cannot create or inspect it directly
- They must use the functions the module provides
pub opaque type Counter {
Counter(value: Int)
}
pub fn new() -> Counter { Counter(0) }
pub fn increment(c: Counter) -> Counter { Counter(c.value + 1) }
pub fn decrement(c: Counter) -> Counter {
case c.value > 0 {
True -> Counter(c.value - 1)
False -> c
}
}
pub fn value(c: Counter) -> Int { c.value }
Counteris visible to other modulesCounter(42)is not: onlynew()can create aCounter- Common use: collections that enforce invariants
- Sorted lists, non-empty lists, validated email addresses, etc.
Organising by Layer
- The
logicmodule contains pure functions that take and return plain data:
pub fn add_task(tasks: List(types.Todo), title: String) -> List(types.Todo) {
[types.Todo(title, types.Active), ..tasks]
}
add_taskhas no side effects: list in, list out- Easy to test: call it, inspect the result
- It uses
types.Todoandtypes.Activebecausemodules/typesis imported at the top of the file - Prepending with
[new_item, ..existing]is idiomatic: it runs in constant time
pub fn render(tasks: List(types.Todo)) -> String {
tasks
|> list.index_map(fn(task, i) {
let num = int.to_string(i + 1) <> ". "
let status = types.display_status(task.status)
num <> status <> " " <> task.title
})
|> string.join("\n")
}
renderis also pure:List(Todo)in,Stringoutlist.index_map(fn(i, task) { ... })maps with a zero-based index- Like Python's
enumerate
- Like Python's
string.join("\n")concatenates lines with a newline separator- Returning a
Stringinstead of printing keeps this function in the logic layer- The caller decides how to display it
- The three-layer structure:
types.gleam: data definitions only, no logiclogic.gleam: pure functions over the types, no IO imports- Entry point: calls logic functions and handles side effects
Testing with gleeunit
- Gleam's test runner is
gleeunit - Tests live in
test/in files named*_test.gleam
pub fn add_task_test() {
let result = logic.add_task([], "write tests")
result
|> should.equal([types.Todo("write tests", types.Active)])
}
pub fn render_single_test() {
let tasks = [types.Todo("learn Gleam", types.Active)]
logic.render(tasks)
|> should.equal("1. [ ] learn Gleam")
}
- Every function ending in
_testruns automatically should.equal(expected)asserts the piped value equalsexpectedshould.be_okandshould.be_errorcheckResultvaluesgleam testruns all tests,gleam test --module foo_testruns one file
Type Aliases
type MyAlias = SomeOtherTypecreates a readable name for an existing type.
pub type Filename = String
pub type Hash = String
pub type FileMap = dict.Dict(Hash, List(Filename))
FilenameandStringare identical to the compiler; one is just more descriptive- Unlike opaque types, aliases do not hide constructors
- Use them to make function signatures communicate intent:
fn group(files: List(#(Filename, Hash))) -> FileMapis clearer thanfn group(files: List(#(String, String))) -> dict.Dict(String, List(String))
Check Understanding
Why separate logic from IO?
Pure functions are straightforward to test: give them known inputs and check the outputs. Functions that print to the screen, read files, or make network requests require more setup to test reliably. By keeping logic in a module that imports no IO modules, you can test all the interesting behaviour with simple tests that do not touch the filesystem or terminal. This pattern is sometimes called "functional core, imperative shell".
What makes a good unit test?
A good unit test is fast, isolated, and specific, and repeatable. Fast: it should complete in milliseconds, with no network or disk access. Isolated: it depends only on the function under test and its inputs. Specific: when it fails, the failure message tells you exactly what went wrong. Repeatable: it gives the same result every time (which means it doesn't depend on the current time, a random number, or anything similar). Pure functions in the logic layer are natural candidates because they satisfy all three criteria automatically.
Exercises
Opaque counter (10 minutes)
Define pub opaque type Counter with new(), increment(), decrement(), and value().
Add a guard in decrement so the counter never goes below zero.
Write three gleeunit tests covering each operation.
Logic layer tests (10 minutes)
Write one unit test for each of add_task, mark_done, and render.
Each test must check a specific outcome.
Type alias clarity (5 minutes)
Add type Title = String and type TaskIndex = Int to types.gleam.
Update the signatures in logic.gleam to use them.
Confirm gleam build still succeeds.
Opaque stack (15 minutes)
Define pub opaque type Stack(a) in a new file src/modules/stack.gleam.
Implement new() -> Stack(a), push(Stack(a), a) -> Stack(a),
pop(Stack(a)) -> Result(#(a, Stack(a)), Nil), and size(Stack(a)) -> Int.
pop should return Error(Nil) on an empty stack.
Write three gleeunit tests: one for push/pop round-trip,
one for popping an empty stack, and one for size after several pushes.
Filter active tasks (10 minutes)
Add a filter_active(tasks: List(types.Todo)) -> List(types.Todo) function
to logic.gleam that returns only tasks whose status is Active.
Write a gleeunit test that creates a list with both Active and Done tasks
and confirms only the active ones are returned.