general-purpose · native · memory-safe

Start with a program.Keep the language when it becomes a system.

Hale is a general-purpose language for scripts, services, and systems. Write ordinary functions, CLIs, files, JSON, HTTP, and WebAssembly. As the program grows, the same language makes ownership, concurrency, placement, effects, and architectural rules visible to the compiler.

$curl -fsSL https://hale-lang.org/install.sh | sh

v0.17 · prerelease — Linux, macOS, and WebAssembly. Language and artifact schemas remain subject to change.

notes.hl
fn main() {
    let path = std::env::arg_or(1, "notes.txt");
    let body = std::io::fs::read_file(path) or "(no notes yet)";
    println(body);
}

A complete Hale program. hale build makes it one static binary; nothing here declares a lifecycle, a topic, or a placement.

general-purpose, checked

What can I build with it?

the same program, three altitudes

It grows without switching languages.

The tutorial's job queue, at three sizes. Watch what each tab adds — and what it leaves alone.

jobs.hl
type Job { id: Int; work: Int; }

fn process(j: Job) -> Int {
    return j.work * j.work;
}

fn main() {
    let j = Job { id: 1, work: 7 };
    println("job ", j.id, " -> ", process(j));
}
what appeared
A type, a function, a main. The whole language a script needs.
what didn't change
Nothing yet — this is the baseline the next two tabs preserve.
service.hl
topic Jobs    { payload: Job; }
topic Results { payload: Result; }

locus Worker {
    bus {
        subscribe Jobs as on_job;
        publish   Results;
    }
    fn on_job(j: Job) {
        Results <- Result { id: j.id, out: process(j) };
    }
}

fn main() {
    Worker { };
    Reporter { };
    Submitter { };
}
what appeared
Two topics and a worker locus: work arrives instead of being looped over, and the channel is typed.
what didn't change
Job and process — the first tab, line for line.
worker-node.hl
main locus WorkerNode {
    params {
        worker:   Worker   = Worker { };
        reporter: Reporter = Reporter { };
    }
    placement {
        worker: cooperative(pool = jobs);
    }
    bindings {
        Jobs: unix("/run/jobs.sock", role: listen);
    }
}
what appeared
Placement and a socket binding, in main only: the worker gets its own pool, and Jobs arrives over a Unix socket from another process.
what didn't change
Every locus and every topic from the service tab. Deployment changed; the program did not.

one language, four altitudes

Use only as much language as the program needs.

  1. scriptfn main()

    Data, functions, control flow. A complete program with nothing else in it.

  2. everydaylocus · std::*

    Files, JSON, HTTP, tests. The altitude you'd reach for Python or Node.

  3. servicetopic · bus

    Long-running loci, typed messages, supervision. Where you'd reach for Go.

  4. systemsplacement · bindings · claims

    Layout, placement, transports, architectural law. Where you'd reach for Rust or C++.

This is control altitude, not system scale — how much machinery you choose to see, not how large the system is (the model page keeps the two axes apart). Each level only reveals more of the same locus: the function you wrote at the top keeps compiling, unchanged, at the bottom.

why hale

Write the system, not the lore.

Your codebase eventually becomes more than code: who owns which state, what talks to what, where things run, what a change is allowed to break. In most languages those facts live in review comments, diagrams, and deployment lore — true until someone forgets. Hale gives them a place in the program, where the compiler can hold them.

The chat room on the right is the everyday version of that bargain: each phrase you would say out loud — a room, each posted message, relay it to everyone — has a line of code to live in.

room.hl
type Msg { room: String; user: String; text: String; }

topic Posted    { payload: Msg; keyed_by room; }
topic Broadcast { payload: Msg; }

locus Room {
    params { name: String = "lobby"; }

    bus {
        subscribe Posted as on_post where key == self.name;
        publish Broadcast;
    }

    fn on_post(m: Msg) {
        Broadcast <- m;
    }
}
a roomlocus Roomeach posted messagesubscribe Postedin that roomkeyed_by roomrelay it to everyonepublish Broadcast

why now

Code is getting cheaper. Consequences are not.

More implementations will be produced by more people and more tools. The scarce part is knowing what each part may touch, publish, block on, allocate, depend on, or destroy. Hale makes those facts part of the program instead of leaving them in review comments and deployment lore.

The same structure at every scale, from a value to a fleet

value & behavior

Start as deep as the structure goes.

A value, a function, a frontier the compiler can close around. Effect classes and budgets are proven here, across every path a call can reach.

frontier.hl
effect io = { syscall, block };

@effects(none: {io})
@budget(alloc_per_call = 0)
fn checksum(seed: Int, n: Int) -> Int {
    let mut acc = seed;
    let mut i = 0;
    while i < n {
        acc = acc * 31 + i;
        i += 1;
    }
    return acc;
}

ownership

Structure is the lifetime.

The cavity is a region: owned, bounded, and freed whole when the locus dissolves. No collector walks it and no annotation describes it. Dissolve the locus and the memory goes with it.

session.hl
locus Session {
    params {
        id: Int = 0;
        seen: Int = 0;
    }

    fn record() {
        self.seen += 1;
    }
}
// dissolve frees the whole region.
// No collector walks it.

nesting

Children open inside their parent.

A child's region is a subregion of the one that owns it. That is why pulling back never shows you a different kind of thing. You were always inside one.

nesting.hl
locus Shard {
    params { slot: Int = 0; }

    contract { expose slot: Int; }
}

locus Index {
    params { width: Int = 0; }

    contract { consume slot: Int; }

    accept(s: Shard) {
        self.width += 1;
    }
}

communication

Topics name the edge.

These loci do not reference one another; they communicate through a declared topic. A message rises to the topic, which sits structurally above them both, and comes back down into every subscriber. A call graph stops at a publish; the compiler keeps going.

desk.hl
type Order { id: Int; desk: String; }

topic Placed { payload: Order; keyed_by desk; }
topic Filled { payload: Order; }

locus Desk {
    params { name: String = "equities"; }

    bus {
        subscribe Placed as on_order where key == self.name;
        publish Filled;
    }

    fn on_order(o: Order) {
        Filled <- o;
    }
}

execution

Placement is separate from logic.

The same locus can share a pool, own a thread, pin to a core, or sit across a process boundary. One block in maindecides, and not one line of the loci changes.

main.hl
main locus App {
    params {
        workers: Worker = Worker { };
    }

    placement {
        workers: pinned(cores = 0..4, replicas = 4);
    }
}
// Four single-threaded replicas, one per core.
// Not one line of Worker changed.

failure

Recovery belongs to the owner.

Nothing fails sideways. Failure has exactly two channels, and both are declared: a value-level failure returns through fallible(E) and is addressed at the call site, and a structural failure travels up to the parent that owns it, with enough context to restart, quarantine, or let it keep rising.

supervise.hl
locus Worker {
    params { valid: Int = 1; }

    closure must_be_valid {
        self.valid ~~ 1 within 0;
        epoch birth;
    }
}

locus Supervisor {
    on_failure(w: Worker, err: ClosureViolation) {
        println(err.locus, " broke ", err.closure);
        quarantine (w);
    }
}

application

One main locus closes a world.

Once the graph is exact it can carry law: named claims over the assembled system, checked as errors, answered with the path that broke them.

claims.hl
group planners = { Planner };
group workers  = { Worker };
group audit    = { Auditor };

main locus Fleet {
    claims {
        isolated:   forbid reaches(planners, audit) via { calls };
        wired:      require subscribes(some workers, topic Tasks);
        one_writer: count publishers(topic Tasks) <= 1;
    }
}

environment

The law outlives the program that states it.

A claimset can be authored once and adopted by every world that has to hold it. Each proves it independently, against its own graph. Composition is union only, so a rule can be added and never quietly weakened.

constitution.hl
group billing  = { Billing };
group research = { Research };

// Authored once. Every world that adopts it proves it again,
// against its own graph.
constitution Core {
    tenant_iso: forbid reaches(billing, research);
    one_writer: count publishers(topic Settled) == 1;
}

main locus App {
    params {
        b: Billing = Billing { };
        r: Research = Research { };
        l: Ledger = Ledger { };
    }

    claims { adopt Core; }
}

fleet

The recursion does not stop at the process.

A plan carries instances, routes, and claims, which is a main locus one scale out. Its certificate is signed over the artifact's exact bytes, so the fleet that runs is the fleet that was certified.

prod.plan.json
{
  "schema": "1.1", "name": "prod",
  "instances": [
    {"id": "oms-0", "artifact": "artifacts/oms.json"},
    {"id": "gw-0",  "artifact": "artifacts/gateway.json"}
  ],
  "routes": [
    {"id": "request", "transport": "unix",
     "publishers":  [{"instance": "oms-0", "topic": "t::Order"}],
     "subscribers": [{"instance": "gw-0",  "topic": "t::Order"}]}
  ]
}

one typed edge, several mechanisms

Build a monolith. Deploy a distributed system.

The loci and their topic sends stay put. Change main to decide where they run and how each edge travels.

main.local.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }
}
Dispatch
Direct call where provable; otherwise the in-process bus.
Boundary
One process, one ownership tree.
Domain code
Unchanged.
main.pinned.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    placement {
        ingest: pinned(core = 1);
        rollup: cooperative(pool = compute);
    }
}
Execution
Dedicated core for ingest; cooperative pool for rollup.
Communication
The same typed topic, still in process.
Domain code
Unchanged.
main.unix.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    bindings {
        Samples: unix("/run/samples.sock", role: listen);
    }
}
Transport
Framed Unix socket with message boundaries preserved.
Failure
The transport is a child locus and enters supervision.
Domain code
Unchanged.
main.shm.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    bindings {
        Samples: shm_ring("/samples",
            slot_count: 1024,
            on_overflow: drop)
            where intra_machine, zero_copy;
    }
}
Transport
Shared-memory ring for a high-rate same-machine edge.
Policy
Capacity and overflow behavior are visible at the seam.
Domain code
Unchanged.
main.adapter.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    bindings {
        Samples: NatsAdapter {
            url: "nats://prod:4222"
        };
    }
}
Transport
A user-written locus supplies the external broker.
Contract
Ordering, retries, and durability belong to the adapter contract.
Domain code
Unchanged.

A successful send means the broker accepted the message under the selected binding's guarantee. A binding that cannot be opened fails the declaring locus at birth; it does not leave a route that claims success while dropping everything.

causality as a contract

The compiler follows the system past the function call.

A call graph stops at a publish. Hale continues through the typed bus graph, across republishers and into the loci that receive the result.

report.hl
@effects(depends: {PublicSummary, ConfigChanged})
locus Report {
    bus {
        subscribe PublicSummary as on_summary;
        subscribe ConfigChanged as configure;
    }

    fn on_summary(s: Summary) { }
    fn configure(c: Config) { }
}

The declaration says exactly which subjects may transitively influence Report. It is a complete set, not a comment.

hale check
declared dependency set violated:
  Report can transitively depend on PrivateFeed

Path:
  subject PrivateFeed
  -> Relay
  -> subject PublicSummary
  -> Report

A violation names the architectural path, including the innocent- looking republished subject that hid the original dependency.

Operational effects

Block, syscall, time, entropy, FFI, publish, spawn, recursion, and allocation.

Causal reach

What a function may cause through calls and topic publication.

Dependency sets

What may transitively influence a locus through its subscriptions.

Budgets

Hard bounds such as zero allocations per call on a hot path.

the toolchain

The checker is fast enough to be a keystroke.

hale check runs a whole-project check fast enough for interactive editor feedback, so the same structured diagnostic reaches an editor, a CI job, and a coding assistant — and the one that fails your build is the one you already read while typing.

supervision.hl
locus WorkerPool {
    accept(c: Worker) { }

    on_failure(c: Worker, err: ClosureViolation) {
        restart(c);
    }
}

the shortest useful introduction

Run a system, then change its deployment.

The playground tour starts with ordinary code and ends with typed communication, ownership, supervision, and placement.