Skip to content

Search content

From F# to Gleam > 7: Modules, Packages, and Tests

3 min readFrom F# to GleamPart 7
A gleam.toml package branches into an orders module with public functions and a test module with passing checks.

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.



Previous in series
From F# to Gleam > 6: From Computation Expressions to use
Next in series
From F# to Gleam > 8: From MailboxProcessor to BEAM Processes