hd-lang

#Working With Data

This page covers everyday jobs that the tour and Learn hd in 10 Minutes do not: read-only access to data, reading JSON into typed config, tests that live in the documentation, concurrent work with a timeout, file reads and writes, pulling fields out of log lines, wiring a program's providers in main, and splitting a program into packages. Each hd program below runs as written; the tests pass with hd test FILE (or in package mode, hd test).

#Read-only data

A &T to plain values, including structs, lists and maps, is read-only all the way down while the path stays within those values. It prevents replacing a field or changing a nested collection through that reference.

A contained &mut keeps its write access. In this team dashboard, view cannot rename the team, but its owner reference can update Ada's completed work. Copying that reference keeps the same access:

pub struct User:
    pub name: string
    pub completed: usize

impl User:
    pub fn complete_task(&mut self) -> void:
        self.completed += 1

pub struct Team:
    name: string
    owner: *mut User

impl Team:
    pub fn lead(&self) -> *User:
        self.owner

    pub fn lead_mut(&mut self) -> *mut User:
        self.owner

pub fn main() -> void $ Console:
    let mut ada = User { name: "Ada", completed: 0 }
    let mut team = Team { name: "Shipping", owner: &mut ada }
    view := &team
    view.owner.complete_task()
    copied := view.owner
    copied.complete_task()
    editor := &mut team
    editor.lead_mut().complete_task()
    reader := view.lead()
    println("${view.name}: ${reader.name}, ${reader.completed} jobs")
Open in playground
Shipping: Ada, 3 jobs

To hand out read-only access to a user, declare the field &User. Another option is a private &mut User field with the getter pair above: lead(&self) -> &User gives read access, and lead_mut(&mut self) -> &mut User gives write access. Outside this module, a &Team holder can only read the user; writing requires a &mut Team holder to call lead_mut. The direct field accesses in this example work because they are inside the module that declares the private field. A trait exposing &self getters can provide the same read-only interface. The rules are in Reading Through A Reference.

#Reusing A Builder

Command, ArgParser, Request and Client use mutable builders. Their constructors return &mut T; each configuration method changes that object and returns the same reference. Chains may be stored or returned.

Assigning alias := base shares the builder. To derive independent configurations, copy its value into separate let mut variables before adding settings:

use std.process.Command

pub fn main() -> void $ Console:
    base := Command::new("git").args(["-C", "repo"])
    let mut status = *base
    status.arg("status")
    let mut log = *base
    log.arg("log")
    println(status.args.join(" "))
    println(log.args.join(" "))
    println(base.args.join(" "))
Open in playground
-C repo status
-C repo log
-C repo

Here status and log each start with a copy of *base; appending their arguments leaves base unchanged.

#Reading Configuration As JSON

A service's config file is the first thing that breaks in production, and the first thing a reviewer reads. hd reads it as typed data: the fields are typed, a missing one is a named error with its path, and an extra one is ignored, so a newer config still loads with an older binary. The type declares Serialize and Deserialize with @derive(Serialize, Deserialize); nothing else is needed. The exact rules are in Typed JSON and Serde:

use std.json.{decode, JsonError}
use std.serde.{Deserialize, Serialize}
@derive(Serialize, Deserialize)
struct Limits:
    retries: i32
    timeout_ms: i32

@derive(Serialize, Deserialize)
struct Config:
    name: string
    limits: Limits

fn load(text: string) -> Result[Config, JsonError]:
    decode::[Config](text)

tests:
    it("reads a nested config"):
        text := "{\"name\": \"shop\", \"limits\": {\"retries\": 3, \"timeout_ms\": 250}}"
        match load(text):
            .Ok(config) =>
                assert_equal(config.name, "shop", reason="the name")
                assert_equal(config.limits.timeout_ms, 250, reason="the nested field")
            .Err(error) => panic("parse failed: $error")

    it("a missing key names its path"):
        match load("{\"name\": \"shop\", \"limits\": {\"retries\": 3}}"):
            .Ok(_) => panic("expected an error")
            .Err(error) => assert_equal("$error", "missing field at $.limits.timeout_ms", reason="the path")

A key that is present wins over the declared default; a key that is missing with no default is an error. For the compact text of a value, encode writes it; decode reads it back. Declare a count you will compare with len() as usize, the type len() returns: a signed field such as i32 does not mix with a usize without a cast.

When the caller itself returns a Result of the same error, ? passes the error up, so no match is needed:

use std.json.{decode, JsonError}
use std.serde.{Deserialize, Serialize}

@derive(Serialize, Deserialize)
struct Limits:
    retries: i32
    timeout_ms: i32

@derive(Serialize, Deserialize)
struct Config:
    name: string
    limits: Limits

fn retry_limit(text: string) -> Result[i32, JsonError]:
    config := decode::[Config](text)?
    .Ok(config.limits.retries)

The function's error type must accept the error: either the same type, or a type with a From implementation for it. So one function can use ? on a time parse and a number parse if its error enum marks a variant @from for each (Error Derivation, ? on a Result).

#Documentation That Runs As Tests

A code block in a doc comment that no one runs drifts out of date. A fenced hd block inside a ## documentation comment is a doc test: hd test runs it like any other test, so the example in the docs is the example that compiles today. The rules are in Doc Tests:

## Money helpers.
##
## The example runs as a test:
## ```hd
## use pkg.total
## use std.testing.assert_equal
##
## assert_equal(total([+300, +200]), +500, reason="sum")
## ```
pub fn total(prices: List[i32]) -> i32:
    let sum = +0
    for price in prices:
        sum = sum + price
    sum

A doc test is its own program: it imports what it uses, from the package under pkg or from std. Run it with hd test in the package. The file's own use lines go above the doc comment: the comment attaches to the declaration immediately after it, so it cannot stand on a use line, and no blank line may separate it from its declaration.

#Concurrent Work With A Timeout

A dashboard needs three lookups, and a user will not wait forever. all! runs several suspending lookups together and returns their results in order; race! returns the first to finish and cancels the rest. Both take cold suspensions — plain calls, not bang calls — so race!(fetch_user(), slow()) starts two candidates and keeps one. A test binds a fake store and a manual clock, so the timeout never actually fires. The exact rules are in Tasks. Two bounds of the pattern are worth knowing before you extend it. An implementation's method must repeat the trait's signature, row included, so a fake store that itself suspends — a slow one — needs the trait to declare the row, as in fn lookup!(&self, key: string) -> string $ Clock. And a ManualClock's sleep! never waits: it completes at once and advances the virtual time, so a test can check the fast path and the clock's reads, but not which of two sleeps finishes first.

use std.task.{all, race}
use std.time.{Clock, ManualClock, Timestamp, s, sleep}

trait Store:
    fn lookup!(&self, key: string) -> string

fn fetch_user!() -> string $ Store:
    $.use(Store).lookup!("user")

fn fetch_orders!() -> string $ Store:
    $.use(Store).lookup!("orders")

fn fetch_profile!() -> string $ Store:
    $.use(Store).lookup!("profile")

fn load_page!() -> (string, string, string) $ Store:
    all!(fetch_user(), fetch_orders(), fetch_profile())

fn slow!() -> string $ Clock:
    sleep!(60s)
    "too slow"

fn load_with_timeout!() -> string $ Store + Clock:
    race!(fetch_user(), slow())

struct FakeStore:
    tag: string

impl Store for FakeStore:
    fn lookup!(&self, key: string) -> string:
        "fake-$key"

tests:
    it("loads all three at once"):
        $.with(Store=&FakeStore { tag: "t" }):
            assert_equal(load_page!(), ("fake-user", "fake-orders", "fake-profile"), reason="all three")

    it("a fast lookup beats the timeout"):
        $.with(Store=&FakeStore { tag: "t" }):
            $.with(Clock=ManualClock::new(Timestamp::from_unix_milliseconds(0))):
                assert_equal(load_with_timeout!(), "fake-user", reason="the lookup wins")

#Composing Reusable Policies

A policy is configuration for one operation: retry it, set a deadline, or call a backup after it fails. Ordinary builders compose that configuration; apply! supplies the work in one block.

This example makes two attempts before using the backup. Applying the same configuration again starts fresh work, and that next call succeeds:

use std.convert.From
use std.task.{Backoff, TimedOut, apply, fallback, retry, with_timeout}
use std.time.{Clock, ManualClock, Timestamp}

@derive(Debug)
enum LoadError:
    Busy
    Deadline(TimedOut)

impl From[TimedOut] for LoadError:
    fn from(error: TimedOut) -> LoadError: .Deadline(error)

struct Source:
    calls: usize

fn read!(source: *mut Source) -> Result[string, LoadError]:
    source.calls += 1
    if source.calls < 3: .Err(.Busy) else: .Ok("primary")

fn backup!() -> Result[string, LoadError]:
    .Ok("cached")

pub fn main!() -> void $ Console:
    source := &mut Source { calls: 0 }
    backoff := Backoff { attempts: 2, initial: 0ms }
    policy := retry(backoff) |> with_timeout(50ms, _) |> fallback(backup, _)
    clock := ManualClock::new(Timestamp::from_unix_milliseconds(0))
    $.with(Clock=clock):
        first := apply!(policy):
            read!(source)
        second := apply!(policy):
            read!(source)
        println("${debug(first)} ${debug(second)} calls=${source.calls}")
Open in playground

The program prints Result.Ok("cached") Result.Ok("primary") calls=3. The policy value holds no completed result. Each application invokes fresh work; the fallback runs only when the retrying operation returns an error.

The pipe builds from the work outward. Here retry(backoff) is inside with_timeout(50ms, _), so one deadline covers all attempts and their backoff sleeps. fallback(backup, _) is outside that deadline and supplies the final result if it fails. For a deadline on each attempt instead, use (timeout(50ms), retry(backoff)) as the inner configuration before adding the fallback, after importing timeout from std.task. Tuple components wrap in order: the first is innermost.

When the deadline wins, it cancels the work and waits for cleanup. That cleanup wait is unbounded unless a cleanup bound is supplied; see Cleanup Bound.

LoadError keeps the error channel explicit. The work and backup both return Result[string, LoadError]; From[TimedOut] converts an expired deadline into that same error type. For plain work, first lift its value into a typed Result before returning it from the apply! block.

Constructing the policy needs no Clock. Executing retry or timeout does, even with a zero backoff delay. Here $.with supplies a ManualClock, so main! needs only Console; without that binding, its row must also name Clock. The immediately completing attempts make this example deterministic; it does not use the manual clock to decide which of two pending sleeps wins.

A predicate supplied through retry(backoff).when(predicate) chooses which errors permit another attempt. Untimed fallback and circuit guarding add no Clock; hedging does. See Task policies for these APIs, their typed errors and cancellation behavior.

#Reading And Writing Files

Importing a CSV, writing a report: the file system is a capability, so a test never touches a real one. FsWrite writes, FsRead reads, and a unit test binds MemoryFs for both. The exact rules are in Fs:

use std.fs.{FsRead, FsWrite, MemoryFs, read_text, write_text}
use std.path.Path
fn save_report!(name: string, total: i32) -> void $ FsWrite:
    _ := write_text!(Path("reports/$name.txt"), "total: $total")

fn read_report!(name: string) -> string $ FsRead:
    read_text!(Path("reports/$name.txt")).expect("the report exists")

tests:
    it("writes and reads back a report"):
        fs := MemoryFs::new()
        $.with(FsWrite=fs, FsRead=fs):
            save_report!("q4", 4500)
            assert_equal(read_report!("q4"), "total: 4500", reason="round trip")

Paths are Path values, so "reports/$name.txt" is one argument, not string surgery on separators. Integration tests get the real file system and a fresh temporary directory per case; see Temporary Directories.

#Scanning Logs With Regex

Operations read logs: count the server errors by status, or find the minute that broke. std.regex compiles a pattern once and captures groups by number or name; it runs in linear time, so a hostile log line cannot stall the scan. The exact rules are in Regex:

use std.regex.Regex
use std.text.r
fn status_of(line: string) -> i32?:
    match Regex::new(r"\"[A-Z]+ /[^ ]*\" (\d\d\d)"):
        .Ok(pattern) =>
            match pattern.captures(line):
                .Some(found) => found.get(1).map(fn(m): m.text.parse::[i32]().expect("digits"))
                .None => .None
        .Err(_) => .None

fn errors_by_status(lines: List[string]) -> Map[i32, usize]:
    let mut counts: Map[i32, usize] = {}
    for line in lines:
        match status_of(line):
            .Some(code) => if code >= 500:
                counts[code] = counts.get(code).unwrap_or(0) + 1
            .None => pass
    counts

tests:
    it("counts server errors by status"):
        lines := ["\"GET /ok\" 200", "\"POST /x\" 500", "\"GET /y\" 502", "\"GET /z\" 500"]
        assert_equal(errors_by_status(lines).get(500), .Some(2), reason="two 500s")
        assert_equal(errors_by_status(lines).get(502), .Some(1), reason="one 502")

Write the pattern as a raw string, r"...", so \d needs no second backslash. A pattern that does not compile is a RegexError, not a panic.

#Wiring A Program

An application's own traits, such as a Mailer and a Repo, are not host capabilities: no host supplies them. Library code asks for them in its row, $ Mailer + Repo, and main is the composition root. It builds the concrete adapters and installs them with $.with, so its own row lists only what the host supplies, here Console. A test is another composition root: it installs fakes the same way, with no mocking library. The rules are in Provider Scopes:

trait Repo:
    fn save(&self, name: string) -> string

trait Mailer:
    fn send(&self, to: string) -> string

fn register(name: string, email: string) -> string $ Mailer + Repo:
    saved := $.use(Repo).save(name)
    sent := $.use(Mailer).send(email)
    "$saved; $sent"

struct FileRepo:
    root: string

impl Repo for FileRepo:
    fn save(&self, name: string) -> string: "file:${self.root}/$name"

struct SmtpMailer:
    endpoint: string

impl Mailer for SmtpMailer:
    fn send(&self, to: string) -> string: "smtp:${self.endpoint}:$to"

struct MemoryRepo:
    namespace: string

impl Repo for MemoryRepo:
    fn save(&self, name: string) -> string: "memory:${self.namespace}:$name"

struct OutboxMailer:
    label: string

impl Mailer for OutboxMailer:
    fn send(&self, to: string) -> string: "outbox:${self.label}:$to"

pub fn main() -> void $ Console:
    $.with(Mailer=&SmtpMailer { endpoint: "smtp.example.com" }, Repo=&FileRepo { root: "data/users" }):
        println(register("Ada", "ada@example.com"))

tests:
    it("registers with test adapters"):
        $.with(Mailer=&OutboxMailer { label: "test" }, Repo=&MemoryRepo { namespace: "case" }):
            assert_equal(register("Ada", "ada@example.com"), "memory:case:Ada; outbox:test:ada@example.com", reason="the installed fakes")
Open in playground

The production and the test adapters share no concrete type; each only implements the trait. A provider is a trait value, so $.with takes it behind a written &: &FileRepo { ... } makes one new object that the whole block shares, and a provider that changes as it runs, such as a fake that records its calls, takes &mut and a &mut self method. In a package, the traits and register go in a library module, and each way of wiring them is its own executable. Each [[executable]] table names an entry module, so src/main.hd wires production and src/dev.hd wires the in-memory adapters (Executables):

[package]
name = "accounts"

[[executable]]
name = "app"
module = "main"

[[executable]]
name = "dev"
module = "dev"
# src/dev.hd
use pkg.{Mailer, MemoryRepo, OutboxMailer, Repo, register}

pub fn main() -> void $ Console:
    $.with(Mailer=&OutboxMailer { label: "local" }, Repo=&MemoryRepo { namespace: "dev" }):
        println(register("Ada", "ada@example.com"))
Open in playground

hd run app and hd run dev run the two. When one executable must choose at startup, give each branch its own $.with. Its row then lists every host capability either branch uses, such as $ Console + Env to read APP_ENV, while Mailer and Repo stay out of it, because both branches install them.

#Testing The Whole Program

A unit test runs on fakes; an integration test runs the real thing. Three tools cover the rest of the job: snapshot_file compares rendered text with a recorded file, hd_run! runs the package's own executable, and temp_dir gives each test case a directory of its own that the runner removes. The exact rules are in Snapshot Files and Running Executables. In the shop package, src/report.hd renders a report and tests/report.hd checks it end to end:

use std.time.Timestamp

pub fn render(name: string, total: i32, at: Timestamp) -> string:
    "report $name: $total cents, at ${at.to_rfc3339()}"
use std.process.Command
use std.testing.{hd_run, snapshot_file, temp_dir}
use std.time.Timestamp
use pkg.report.render

it("renders the quarterly report"):
    snapshot_file(render("q4", 4500, Timestamp::from_unix_milliseconds(0)))

it("runs the built program in its own directory"):
    let out = hd_run!(*Command::new("shop"))
    assert_equal(out.status, 0, reason="a clean exit")
    dir := temp_dir()
    assert_equal("$dir".len() > 0, true, reason="each case gets its own directory")

The first run fails until you record the snapshot with hd test --update; every later run compares against it. A snapshot that should change is re-recorded with another --update, and the diff is the review. Tests that need deterministic randomness or arguments bind SeededRandom or ScriptedArgs the same way as MemoryFs.

#Packages And Dependencies

A program larger than a file is a package: an hd.toml manifest beside a src/ folder. Each file under src/ is a module named by its path, so src/shop/cart.hd is shop.cart. A folder that holds modules needs a mod.hd, the folder's own module, and its pub use lines list what the folder offers. A use starts at one of five roots: pkg for this package, std for the standard library, dep.NAME for a dependency, and self and super for the current module and its parent. The rules are in Path-Inferred Modules and Use Roots.

A path dependency names any local package, and neither package needs a workspace. Create a library and an application side by side, then add the library to the application:

hd new --lib money
hd new --app shop
cd shop
hd add ../money

hd add writes the requirement into shop/hd.toml, under the library's package name:

[package]
name = "shop"

[dependencies]
money = { path = "../money" }

The library's pub items in money/src/lib.hd are its interface:

## An amount of money in whole cents.
pub struct Money:
    pub cents: i64

## The amount `cents`.
pub fn of_cents(cents: i64) -> Money:
    Money { cents: cents }

## The amount as dollars and cents, such as `$12.50`.
pub fn show(money: Money) -> string:
    rest := (money.cents % 100).to_string().pad_start(2, '0')
    "$${money.cents / 100}.$rest"

The application reaches it as dep.money, by the key in its manifest:

# shop/src/main.hd
use dep.money.{of_cents, show}

pub fn main() -> void $ Console:
    println("total: ${show(of_cents(1250))}")
Open in playground

Run commands inside a package directory: in shop, hd run prints total: $12.50.

A released package is a repository path and a minimum version, as json = "github.com/acme/json@2.1.0". hd add acme/json adds the newest release of a GitHub repository, and hd add acme/json@2.1 the newest 2.1 release. hd update moves every dependency to the newest release on its compatibility line, hd remove NAME deletes one, and hd fetch fetches what the cache lacks. They keep hd.sum, the recorded hashes, up to date; commit it beside the manifest (Dependency Commands).

A tagged release cannot hold a bare path requirement, since the path means nothing in a fetched copy. A package that you release adds the version a fetched copy should use, as Cargo does (Path Requirements):

[dependencies]
money = { path = "../money", version = "0.4.2" }

Several packages of one repository can share one version selection and one hd.sum through a workspace: an hd.toml at the root that lists the members and declares no package. At the root, hd test tests every member and -p NAME picks one, as in hd run -p shop (Workspace Mode):

[workspace]
members = ["money", "shop"]

A workspace root has no package, so it has no tasks/. Tasks for the whole repository go in a member of their own, such as tools/, as Cargo's xtask pattern does: hd run -p tools release runs tools/tasks/release.hd.

#Publishing A Package

A package others fetch needs a version and a hash everyone can check. A version is a git tag: v2.3.0 at the repository root, lint/v2.3.0 for a package in the lint subdirectory. A manifest never states its own version. hd.sum, committed beside the manifest, records the hash of each selected tree, and a fetched tree that differs is rejected. The exact rules are in Versions and Integrity:

git tag v2.3.0        # version 2.3.0 at the repository root
git tag lint/v2.3.0   # version 2.3.0 of the package in lint/

A requirement is a minimum, and the build selects the largest minimum named anywhere in the graph — minimal version selection, with no lockfile:

[dependencies]
json = "github.com/acme/json@2.1.0"   # a minimum: 2.3.0 may be selected

A manifest can also state the minimum toolchain that builds it, and a root manifest can pin one exactly:

[package]
name = "shop"
hd = "0.2.0"   # the minimum toolchain that builds this package

hd fetch fetches every selected version the cache lacks, as CI does before a build. Read next: Dependencies.

#Deeper Collections

List and Map cover most days. Three more shapes cover the rest: Set for distinct values, sorted and group_by for ordering and bucketing, and the adapters on any iterator — filter, map, fold. The exact rules are in Collections and Iterators:

use std.collections.Set
struct Item:
    name: string
    price: i32
    tags: List[string]

fn sample() -> List[Item]:
    [Item { name: "apple", price: 3, tags: ["fruit", "fresh"] },
     Item { name: "bread", price: 5, tags: ["bakery"] },
     Item { name: "cheese", price: 8, tags: ["dairy", "fresh"] }]

tests:
    it("groups by tag and sorts by price"):
        items := sample()
        by_tag := items.group_by(fn(item: Item) -> string: item.tags[0])
        assert_equal(by_tag.get("fruit").map(fn(list): list.len()), .Some(1), reason="one fruit")
        cheap := items.sorted_by_key(fn(item: Item) -> i32: item.price)
        assert_equal(cheap[0].name, "apple", reason="cheapest first")

    it("collects each tag once"):
        let mut seen: Set[string] = Set::new()
        let mut kept: List[string] = []
        for item in sample():
            for tag in item.tags:
                if seen.insert(tag):
                    kept.push(tag)
        assert_equal(kept, ["fruit", "fresh", "bakery", "dairy"], reason="first occurrences, in item order")

A Set keeps each value once, in the order it first appeared. Deque and Heap serve a queue and a priority queue; see Collections.

#Points And Intervals In Time

Two types cover the clock: a Timestamp is a point in UTC time, and a Duration is a distance between two points. Text in and out is RFC 3339, so a config or a log line reads the same everywhere. An Instant is a monotonic reading for measuring intervals, never a wall time. The exact rules are in Time:

use std.time.{Duration, Instant, Timestamp, min, s}

fn deadline(now: Timestamp) -> Timestamp:
    now + 30s

fn elapsed(start: Instant, end: Instant) -> Duration:
    end.since(start)

tests:
    it("reads and writes RFC 3339 text"):
        at := "2026-10-06T12:00:00Z".parse::[Timestamp]().expect("valid")
        assert_equal(at.to_rfc3339(), "2026-10-06T12:00:00Z", reason="round trip")
        assert_equal(deadline(at).to_rfc3339(), "2026-10-06T12:00:30Z", reason="30s later")

    it("measures an interval"):
        let span: Duration = elapsed(Instant::from_milliseconds(1000), Instant::from_milliseconds(1600))
        assert_equal("$span", "600ms", reason="600 ms")

A Duration displays as 1h2min3.5s, and sleep!(d) waits on the clock. A test binds ManualClock, so waiting costs no real time; see Concurrent Work With A Timeout.

#Building Text

trim and split take text apart. The other direction has three tools: join for a list with a separator, StringBuilder for a loop that appends, and repeat for padding. chars and bytes walk a string by character or by byte. The exact rules are in Text:

use std.text.StringBuilder

fn csv_row(cells: List[string]) -> string:
    cells.join(",")

fn banner(text: string) -> string:
    out := StringBuilder::new()
    out.push("=".repeat(4))
    out.push(" ")
    out.push(text)
    out.push(" ")
    out.push("=".repeat(4))
    out.build()

tests:
    it("joins and builds"):
        assert_equal(csv_row(["a", "b", "c"]), "a,b,c", reason="joined")
        assert_equal(banner("SALE"), "==== SALE ====", reason="built")

    it("reads characters and bytes"):
        assert_equal("héllo".chars().collect().len(), 5, reason="five chars")
        assert_equal("héllo".bytes().collect().len(), 6, reason="six bytes")

A string is bytes: len() counts bytes, chars() counts characters. ASCII text needs no care; anything else, walk it with chars().