An F# project lists source files in compile order. Gleam discovers modules from their paths and follows imports. There’s no .fsproj list to reorder, though circular module dependencies are still rejected.
Table of contents
On this page
Files Define Modules
Starting with the project from part 1, use this layout:
learning_gleam/
gleam.toml
src/learning_gleam.gleam
src/learning/orders.gleam
test/learning_gleam_test.gleam
src/learning/orders.gleam defines the module learning/orders. Import it with import learning/orders, then call orders.new(...). Unlike an F# module Learning.Orders declaration, the module’s name comes from its file path.
Protect Construction, Not Just Field Names
In F#, a private union case can put validation behind a factory function:
type Order = private Order of customer: string * quantity: int
let createOrder (customer: string) quantity =
match customer.Trim(), quantity > 0 with
| "", _ -> Error "Customer is required"
| _, false -> Error "Quantity must be positive"
| name, true -> Ok (Order (name, quantity))
let orderLabel (Order (customer, quantity)) =
$"{customer}: {quantity}"scratch.fsx
Gleam’s pub opaque type exposes the type but hides its constructor and fields outside the defining module:
import gleam/int
import gleam/string
pub opaque type Order {
Order(customer: String, quantity: Int)
}
pub fn new(customer: String, quantity: Int) -> Result(Order, String) {
case string.trim(customer), quantity > 0 {
"", _ -> Error("Customer is required")
_, False -> Error("Quantity must be positive")
name, True -> Ok(Order(name, quantity))
}
}
pub fn label(order: Order) -> String {
order.customer <> ": " <> int.to_string(order.quantity)
}src/learning/orders.gleam
Another module can’t bypass new by calling the constructor or copying fields directly. That makes opacity useful for invariants, not just for hiding punctuation. Ordinary public records are still fine when callers should be free to construct them.
Our entry point uses only the public functions:
import gleam/io
import learning/orders
pub fn main() -> Nil {
case orders.new(" Ada ", 3) {
Ok(order) -> io.println(orders.label(order))
Error(reason) -> io.println(reason)
}
}src/learning_gleam.gleam
gleam run prints Ada: 3.
Packages and the Lockfile
Dependencies live in gleam.toml, with packages usually fetched from Hex rather than NuGet. The generated project already includes the standard library and Gleeunit, a test runner:
name = "learning_gleam"
version = "1.0.0"
[dependencies]
gleam_stdlib = ">= 1.0.0 and < 2.0.0"
[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"gleam.toml
gleam add package_name updates dependency constraints. manifest.toml records the resolved versions. Commit it so another checkout uses the same resolution, and use gleam update when you mean to change that resolution.
Try It with Tests
Replace the generated test module with this:
import gleeunit
import learning/orders
pub fn main() -> Nil {
gleeunit.main()
}
pub fn order_label_test() {
let assert Ok(order) = orders.new(" Ada ", 3)
assert orders.label(order) == "Ada: 3"
}
pub fn invalid_order_test() {
assert orders.new(" ", 3) == Error("Customer is required")
assert orders.new("Ada", 0) == Error("Quantity must be positive")
assert orders.new("Ada", -1) == Error("Quantity must be positive")
}test/learning_gleam_test.gleam
Gleeunit discovers public test functions whose names end in _test. Here let assert is appropriate because failing to construct the known-good fixture should fail the test.
gleam format --check src test
gleam check
gleam test
The F# factory can be checked without a test framework for this small example:
assert (createOrder " Ada " 3 |> Result.map orderLabel = Ok "Ada: 3")
assert (createOrder " " 3 = Error "Customer is required")
assert (createOrder "Ada" 0 = Error "Quantity must be positive")
assert (createOrder "Ada" -1 = Error "Quantity must be positive")scratch.fsx
Keep the same split as the code grows: pure domain functions in modules, effects in the entry point, and tests against the public contract.
Further reading: Writing Gleam, opaque types, and Gleeunit.