Less language. More compiler.

One language from a five-line script to a dynamic distributed system.

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

v0.23 · prerelease. Prebuilt for Linux x86-64, Linux ARM64 and Apple Silicon. Compiles to native binaries and to wasm32. Syntax and artifact formats still change between releases. Apache-2.0, built on LLVM 18.

notes.hleveryday
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);
}
hale build · one static binaryhale check · ok

A complete program. hale build makes it one static binary. It declares no lifecycle, topic or placement, and will not need to until it has a second part.

What Hale does for you

What you can write with it today

docs/tutorial/job-queue

A script, then a service, then a deployment.

The job queue from the tutorial at three sizes. Each step adds a block. The earlier blocks are not edited.

added at this stepkept  unchanged from the step before

jobs.hlprogram
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));
}
added
A type, a function and a main. That is everything a script needs.
unchanged
Nothing yet. This is the baseline the next two steps keep.
service.hlservice
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 { };
}
added
Two topics and a Worker locus. Work arrives on a typed topic; nothing loops over it.
unchanged
Job and process, line for line.
  • type Job
  • fn process
worker-node.hlsystem
main locus WorkerNode {
    params {
        worker:   Worker   = Worker { };
        reporter: Reporter = Reporter { };
    }
    placement {
        worker: cooperative(pool = jobs);
    }
    bindings {
        Jobs: unix("/run/jobs.sock", role: listen);
    }
}
added
A placement block and a socket binding, both in main. The worker gets its own pool, and Jobs arrives over a Unix socket from another process.
unchanged
Every locus and every topic from the service. Only the deployment changed.
  • topic Jobs
  • topic Results
  • locus Worker
  • locus Reporter

How much of the language a program uses

  1. scriptfn main()

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

  2. everydaylocus · std::*

    Files, JSON, HTTP and tests. The level you would use Python or Node for.

  3. servicetopic · bus

    Long-running loci, typed messages and supervision. The level you would use Go for.

  4. systemsplacement · bindings · claims

    Layout, placement, transports and claims over the whole graph. The level you would use Rust or C++ for.

This is how much of the machinery you choose to see. How large the system is, its scale, is a separate axis, and the model page keeps the two apart. Each level shows more of the same locus, and a function written at the top compiles unchanged at the bottom. The guide has two more parts that go outward: the organization that changes a program, and what several programs and their people share.

A chat room, in Hale.

Say the service out loud. A chat room takes each message posted to it and relays it to everyone in the room. The code you then write in most languages looks nothing like that sentence. There is a connection registry, a member list with a lock around it, a broadcast loop and some async plumbing, and by the time it works the idea is buried.

Each phrase has one place to live in Hale, in the order you said it. The bus is the routing table, so with ten rooms a posted message costs one delivery, and the handler never filters.

room.hlservice
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 chat roomlocus Room
  • each message posted to itsubscribe Posted
  • in the roomkeyed_by room
  • relays it to everyonepublish Broadcast

Why a new language in 2026

Code is written faster than it is read, and more of it is written by models. The two rules that review used to enforce by hand, one owner for every piece of state and no reaching sideways, are the only shape this language accepts, so the enforcement moved out of review and into the build. The checker reads for shape and rules. A person reads for whether it is the right shape for the problem.


The same construct, from a function to a fleet

Hale folds classes, modules, actors and services into one construct, a component that owns its state and talks only through declared messages. It is called a locus, and every box below is one. A value's unit, a function's effects, a component's memory, an application's rules and a deployment's routes are the same idea at different sizes, which is why one checker can follow all of them.

fleet prod.plan.json
environment constitution.hl
application claims.hl
locus ownership · nesting · communication · execution · failure
value money.hl · frontier.hl

A value says what it counts. A function says what it may do.

A program declares its units and the equations between them, then integer types counted in them: money in cents, sizes in bytes, rates in basis points. Arithmetic stays exact, widening is free, and narrowing is written where it happens, with the rounding you chose. Units, quantities, identities and ranges arrived in 0.23.

Ten effect classes describe what a function can reach: syscall, block, time, entropy, env, ffi, publish, spawn, recursion and alloc. A function can declare which it uses, plus a numeric budget such as zero allocations per call, and the compiler proves the declaration across everything the function can reach. A violation names the path.

money.hlvalue · new in 0.23
unit cent;
unit USD = 100 cent;
unit pct = 1/100;
unit bp = 1/100 pct;

type Money  = quantity Int in cent;
type Ledger = Money { round: half_even; }
type Rate   = quantity Int in bp;

fn main() {
    let fee = 1_250_000USD * 3bp;     // exactly 375 USD
    let odd = 1_234_567USD * 3bp;     // 370.3701 USD: still exact here
    let paid: Ledger = odd;           // rounded where the policy is
    let cents = odd.in(cent) or ceil; // or name the rounding yourself
    println(fee, " ", odd, " ", paid, " ", cents);
}
frontier.hlvalue
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;
}

Memory belongs to the locus.

A locus owns a region, and dissolving the locus frees the whole region. There is no collector and no lifetime annotation, because no pointer can cross sideways between loci.

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

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

Children live inside their parent.

A child's region is part of the region that owns it. A parent can call its children, and nothing can reach a locus it does not own. This is the one-owner rule as the compiler sees it.

nesting.hllocus
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;
    }
}

A topic is the only way across.

Loci hold no references to each other. A message goes up to a declared topic and back down to every subscriber. Publish and subscribe are declarations, so the compiler sees the whole message graph: who publishes, who subscribes, whether the payloads match, and whether a handler can ever run.

desk.hllocus
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;
    }
}

Where a locus runs is decided in main.

A placement block maps loci onto a shared pool, a dedicated thread, a pinned core or a NUMA node. The loci do not mention threads. This example runs four single-threaded replicas without a change to Worker.

main.hllocus
locus Worker { params { valid: Int = 1; } }

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 goes to the owner.

A call that can fail says so in its type, and the caller decides what to do at the call site. A broken invariant in a locus goes to the parent that owns it, which can restart it, quarantine it, or pass it up. Nothing travels sideways.

what faileddeclared ashandled
a callfallible(E) in its typeat the call site
an invarianta closure on the locusby the parent: restart, quarantine, or pass it up
supervise.hllocus
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);
    }
}

A claim is one sentence about the whole graph.

Claims are declared in main and checked by hale check as errors. Six forms cover absence, existence, cardinality, coverage, boundary inventories and cost. A violation prints a minimal countermodel in your own names, and claims compile to no code. The compiler refuses to certify a call it cannot resolve, and says where it could not see.

claims.hlapplication
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;
    }
}

A constitution is a claimset shared across programs.

Write the claims once. Every entry point that adopts the constitution proves them against its own graph. Composition is by union only, so an environment can add a rule and cannot weaken one.

constitution.hlenvironment
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; }
}

A fleet plan is checked the same way.

Each binary emits the topology it proved. A plan names the instances and routes production runs, and hale fleet check checks claims across them, so two correct binaries wired into an incorrect fleet are caught here. The certificate is signed over the artifact's bytes.

prod.plan.jsonfleet
{
  "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"}]}
  ]
}

An abstract systems language

A systems language lets you declare memory and layout, and checks them. Hale also lets you declare the parts of a system: who owns which state, what talks to what, where each part runs, what a function may do, what a number counts. The compiler checks those too. Units, effects, claims and targets are all declarations it holds you to.

Is this Erlang's OTP with types?

The resemblance is real: components that own their state, messages between them, and supervisors that decide what happens when one fails. OTP finds its process graph at run time. Hale's compiler knows the whole graph before the program runs, so a message on an undeclared topic, a payload that does not match, or a broken rule about who may reach what fails the build. A Hale program compiles to a native binary with no virtual machine and no garbage collector, and a message between two parts on one thread can compile to a direct call. Where each part runs, on a pool, a pinned core or another process, is one block in main.

Placement and bindings

Five main blocks deploy the same two loci, Ingest and Rollup. Only where they run and how Samples travels changes.

bindingmainwhat it doesingest, rollup
Localmain.local.hl
main.local.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }
}
Dispatch
A direct call where the compiler can prove it, otherwise the in-process bus.
Boundary
One process, one ownership tree.
ingest, rollupUnchanged.
Pinnedmain.pinned.hl
main.pinned.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    placement {
        ingest: pinned(core = 1);
        rollup: cooperative(pool = compute);
    }
}
Execution
A dedicated core for ingest, a cooperative pool for rollup.
Communication
The same typed topic, still in one process.
ingest, rollupUnchanged.
Unixmain.unix.hl
main.unix.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    bindings {
        Samples: unix("/run/samples.sock", role: listen);
    }
}
Transport
A framed Unix socket. Message boundaries are preserved.
Failure
The transport is a child locus, so it is supervised like one.
ingest, rollupUnchanged.
Shared memorymain.shm.hl
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
A shared-memory ring for a high-rate edge on one machine.
Policy
Capacity and overflow behaviour are declared at the seam.
ingest, rollupUnchanged.
Adaptermain.adapter.hl
main.adapter.hl
main locus App {
    params {
        ingest: Ingest = Ingest { };
        rollup: Rollup = Rollup { };
    }

    bindings {
        Samples: NatsAdapter {
            url: "nats://prod:4222"
        };
    }
}
Transport
A locus you wrote supplies the external broker.
Contract
Ordering, retries and durability are the adapter's contract.
ingest, rollupUnchanged.

A send succeeds when the broker has accepted the message under the binding's guarantee. A binding that cannot be opened fails the declaring locus at birth, so no route is left that reports success while dropping messages.

What the compiler sees past a publish.

A call graph ends at a publish. Hale continues through the bus, through loci that republish, and into the loci that receive the result.

report.hlsystems
@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 lists every subject that may transitively influence Report. The list is complete, so anything not on it is an error.

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

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

The violation names the path, including the republished subject that carried the hidden dependency.

Effects

syscall, block, time, entropy, env, ffi, publish, spawn, recursion and alloc, proven over the whole call graph.

Causal reach

What a function may cause through calls and publishes.

Dependency sets

What may influence a locus through its subscriptions.

Budgets

Numeric bounds, such as zero allocations per call.

One binary: build, run, check, verify, test, bench, fmt, doc, fetch and fleet.

hale check runs a whole-project check quickly enough to run as you type. The language server and the MCP server are compiled into the same binary, so an editor, a CI job and a coding assistant read the same diagnostic.

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

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

What it rejects, what it misses, and where it is slower

The short version, for a reader who already writes Rust, Zig or Go. The proof page shows each check with the witness it returns.

The build fails on

  • An undeclared topic

    A publish or subscribe that names a topic nothing declares.

  • A payload mismatch

    A publisher and a subscriber that disagree on a topic's type.

  • An unhandled failure

    A call that can fail with no or saying what happens.

  • A broken effect or budget

    A function that reaches an effect it ruled out, or allocates past its budget, with the path that does it.

  • A broken claim

    A path the rules forbid, such as billing reaching research, returned as a counterexample.

  • A silent rounding

    A narrowing to a coarser unit with no rounding policy.

It does not check

  • What a handler computes

    +1 and +100 have the same structure. Correctness of the arithmetic is your tests' job.

  • Time

    Budgets count allocations, stack depth, blocking points, publishes and fan-out. None of them bounds latency.

  • Information flow

    A sealed component keeps its key, and a value derived from that key is not tracked.

  • Calls it cannot resolve

    A call through a function value is reported as unknown, a third answer beside yes and no, and a hard contract fails closed on it.

  • The runtime's own code

    Models of its synchronization protocols are checked, and their match to the shipped C is maintained by hand.

Against Go

Hale v0.22.0 on 29 Sept 2026, AMD Ryzen 7 9800X3D / x86_64 / Linux, the same workload shape in each language. Faster at message dispatch, JSON parsing and collections; slower at plain function calls and at creating components.

benchmarkHaleGoHale is
bus_dispatch (100k typed messages)178 µs458 µs2.6× faster
json_parse (200k 7-field parses)61.8 ms143.6 ms2.3× faster
form_vec_push (500k)671 µs2.63 ms3.9× faster
form_vec_get (500k)11.8 µs39.1 µs3.3× faster
loop_overhead (100M xor-reduce)1.79 ms19.8 ms11.0× faster *
fn_call (10M free-fn calls)11.5 ms7.7 ms1.5× slower
locus_instantiation (100k)1.52 ms153 µs9.9× slower
coord_with_churn (2000 children)35.0 µs2.0 µs17.5× slower

* LLVM vectorizes that reduction and Go's compiler does not, so it measures vectorization of reducible loops, and says little about loop speed in general.

Every component owns a memory region, so calls and spawns pay region setup that message dispatch and collections amortize away. A cheaper call protocol and spawn path are on the roadmap.

hale dna init . && hale dna dev

One program for building, running, governing and recording an application.

hale dna init generates it next to your code and leaves the application untouched: people with final say, called the Board; a model-backed reviewer that decides within limits they set, called the Leader; the models and the budget it may use; and the parts that make each change in a sandbox, prove it with the toolchain, show it to whoever holds the authority, apply exactly what was approved, restart or redeploy it, and watch it. Its working state lives in memory it owns. Its history is a branch of your repository, refs/dna/journal, one commit per event.

terminalthe demo application, editor on scripted models
$ hale dna init .            # the organization, generated next to your code; the app untouched
$ hale dna dev               # the organization and the application on this machine
$ hale dna ask document the chat server in main.hl
task t1 born for intent i1a08c0786c5 [pending]

$ hale dna review m1
review m1 [pending]: apply m1 (docs): document the chat server in main.hl?
  needs leader · candidate bf94e503c1c002f277248b14b6afd1910bb8ce6f

source diff (git 232dc8f18dde .. bf94e503c1c0):
  +// documented by the organism: Echo answers every Ping

semantic diff (hale model diff, baseline .. candidate):
  classification: source-only  ·  no semantic differences

evidence (fmt=0 check=0 verify=0 test=0 diff=0 rollback=0 fleet=0):
  check      yes    0 e3b0c44298fc
  test       yes    0 184a3407ce31
  fleet      yes    0 b33b2e832b21
  rollback   yes    0 89978376be81

$ hale dna review m1 approve --as riley --comment "fine"
review m1 settled: approve by riley
hale dna dev: expression restarted (pid 1875137) as 3c9b9327e480d349 build 7a489ad3e72c
hale dna dev: m1 observed healthy for 5s as 3c9b9327e480d349
Source diff

The candidate commit against the baseline, as git shows it.

Structural diff

hale model diff: what changed in the program's shape. Who talks to whom, which effects are reached, whether a rule went from holding to violated.

Evidence

check, verify, test, the fleet it would deploy and a rehearsed rollback, each kept under its own hash and pinned to the commit.

Verdict

The Leader decides inside the grant, with its reasoning and cost on the record. The Board decides outside it, or when the law, an effect or the fleet's shape moves, and the Board is people.

What it needs, and what it does not claim

  • git

    The record lives in the repository. Alone on one machine no remote is needed. A team, or nodes, need one.

  • docker compose

    hale dna dev brings up its private services with it: Postgres for its working state, NATS for messaging between its parts, Prometheus for monitoring.

  • A model

    A key in the vault, ollama on your PATH, or a coding harness on it. DNA still runs without one; the Leader cannot decide anything, and every review waits for you.

  • Every change waits

    Each waits for a verdict, the Leader's inside its grant or the Board's. A restart is a restart. Live state does not carry across.

  • A blind spot

    The structural view cannot see what a handler computes. A +1 becoming +100 is invisible to it, so the source diff is never optional, for people or for the Leader.

  • Shared infrastructure

    Several applications and teams sharing records, access and identity. Designed and partly built; the DNA page says which pieces are in the tree.

What it is not.

Built in the open

The compiler, the runtime, the libraries and this site are public, and so is every change to them.

The changelog lists 39 releases since 16 Jul 2026. The latest:

  1. v0.23.07 Oct 2026one model, and units
  2. v0.22.030 Sept 2026the organism, part by part
  3. v0.21.021 Sept 2026the DNA prerelease
  4. v0.20.014 Sept 2026an organization written in Hale
  5. v0.19.24 Sept 2026helpers get their speed back

Try it in the browser.

The playground runs the same compiler you would install. The tour starts with a script and ends with a program split across processes.