Etch

A programming language where the compiler proves your code safe before it runs.

Bugs ruled out before the program runs. Arrays stay in bounds, integers in range, loops terminate, writes don't interfere.
Native code uses the proven facts. No redundant checks. Performance comparable to clang -O2.

Self-hosted compiler · arm64 backend · no LLVM or C

644b9c35 · 3061 commits · updated 2026-10-14 · source not yet public — repo links go live with the release

Checks

The compiler rejects programs it cannot prove safe. When it does, it shows you the line and a short error. Full diagnostics and test file paths are available in a collapsible detail.

Integer ranges

Argument b = 12 exceeds parameter range int(0..10)

Rejected
fn f(x: int(0..10)) -> int { return x; }
fn main() {
    let a = 6;
    let b = a + 6;
    println(f(b));
}
Fixed
fn f(x: int(0..10)) -> int { return x; }
fn main() {
    let a = 6;
    let b = a + 4;
    println(f(b));
}
10

argument not proven inside interval

Full diagnostic

tests/cases/d53_r3a1_call_rej_straight_oob.etch

error[E_REFINED_STORE_UNPROVEN] (refinement) fn main, stmt 2: argument to an int(lo..hi) parameter not proven inside its interval by the index prover — guard the value before the call (an if, an early return, min or max) [fix-class: evidence]

Overflow

Arithmetic escapes ±(253−1)

Rejected
fn main() {
    let a = 1000000;
    let b = a * a * a * a;
    println(b);
}
Fixed
fn main() {
    let a = 1000000;
    let b = a * a;
    println(b);
}
1000000000000

arithmetic provably escapes the int domain

Full diagnostic

tests/cases/arith_oob_straightline.etch

error[E_OVERFLOW] (refinement) fn main, stmt 1: arithmetic provably escapes the int domain [fix-class: evidence]

Termination

Loop variable i never changes

Rejected
fn f(n: int) -> int {
    let i = 0;
    let acc = 0;
    while true {
        acc = acc + 1;
        if i >= n { break; }
    }
    return acc;
}
Fixed
fn f(n: int) -> int {
    let i = 0;
    let acc = 0;
    while i < n {
        acc = acc + 1;
        i = i + 1;
    }
    return acc;
}
fn main() { println(f(10)); }
10

loop without provable ranked measure

Full diagnostic

tests/cases/total_reject_break_no_incr.etch

error[E_TERMINATION_EVIDENCE] (termination) fn f, stmt 2: loop/recursion without provable ranked measure [fix-class: evidence] [subject: `while true`] [retiring-evidence: a `decreases <var>` measure, or the counting form `while i < n { …; i = i + 1 }` with `n` loop-invariant]

Array bounds

Write to a drops the proof that a[2] is in bounds

Rejected
fn main() {
    let a: []int = [1, 2, 3];
    if 2 < a.length() {
        a.pop();
        println(a[2]);
    }
}
Fixed
fn main() {
    let a: []int = [1, 2, 3];
    if 2 < a.length() {
        println(a[2]);
        a.pop();
    }
}
3

index not proven in bounds

Full diagnostic

tests/cases/bounds_reject_owner_pop.etch

error[E_INDEX_UNPROVEN] (refinement) fn main, stmt 1: index not proven in bounds — guard it, iterate 0..xs.length(), or use checked_get [fix-class: evidence]

Borrows

Write through a while borrow b is live

Rejected
fn main() {
    let a: []int = [1, 2, 3];
    let b: []int = a;
    a.push(4);
    println(b.length());
}
Fixed
fn main() {
    let a: []int = [1, 2, 3];
    let b: []int = a.clone();
    a.push(4);
    println(b.length());
}
3

owner written while view is live

Full diagnostic

tests/cases/borrow_owner_write_push_reject.etch

error[E_BORROW_OWNER_WRITE] tests/cases/borrow_owner_write_push_reject.etch:main:0.1: `a` is written by `.push()` while its view `b` is live (RFC 002 §7.1); fix-class: clone at the view (`.clone()` on the value `b` names) or move the write past the view's last use

Error handling

checked_get returns an option that must be exhaustively matched

Rejected
fn unsafe_get(xs: []int, i: int) -> int {
    match checked_get(xs, i) {
        .some { v } => { return v; }
    }
    return 0;
}
Fixed
fn safe_get(xs: []int, i: int) -> int {
    match checked_get(xs, i) {
        .some { v } => { return v; }
        .none => { return 0 - 1; }
    }
}
fn main() {
    let xs: []int = [10, 20, 30];
    println(safe_get(xs, 1));
    println(safe_get(xs, 5));
}
20 -1

match does not cover variant(s): none

Full diagnostic

tests/cases/error_handling_checked_get_reject.etch

error[E_NONEXHAUSTIVE_MATCH] (structure) fn unsafe_get, stmt 0: match does not cover variant(s): none [fix-class: evidence]

Etch has no exceptions or panics. Operations that can fail return option or enum types that must be matched exhaustively. checked_div, checked_mod, and checked_get are built-in fallible operations. The Outcome type and checked profile for recoverable errors are specified in RFC 011 but not yet implemented.

Parameter writes

Function writes parameter x without declaring effects { write(x) }

Rejected
fn f(x: int) -> int { x = x + 1; return x; }
Fixed
fn f(x: int) -> int { let y = x + 1; return y; }
fn main() { println(f(1)); }
2

parameter written without authorization

Full diagnostic

tests/cases/256_param_write_scalar_reject.etch

error[E_PARAM_WRITE] (structure) fn f, stmt 0: parameter `x` is written but the declaration does not authorize it — add `effects { write(x) }` to the signature, or derive a local (`let q = x;`) if only a local value is meant (RFC-002 §7.2) [fix-class: evidence]

Derived values & Reactivity

derived values recompute when dependencies change

fn main() {
    let a = 1;
    derived b { a + 10 }
    derived d { b + a + 100 }
    println(d);  // 112
    a = 5;
    println(d);  // 120
}

Effects that form cycles must converge

fn main() {
    let a = 1;
    derived da { a + 1 }
    effect { a = if (da < 3) { da } else { 3 }; }
    a = 5;
    println(a);  // 3 — converges
}

fn diverge() {
    let a = 1;
    derived da { a + 1 }
    effect { a = da; }  // rejected: no bound
    a = 5;
}

Cycles admit min(g, K) or max(g, K) with K independent of the value

More checks: derived bodies, undecided proofs, reactive read sets — see Specification

Performance

The backend has no optimization passes of the LLVM kind: no alias analysis, loop-invariant code motion or bounds-check elimination. The facts those passes would recover are established while the program is checked, and the backend uses them directly. In the matrix multiply below, the shaped type [n][n]int fixes every row's length at n, so no index in the inner loop needs a bounds check.

tests/perf/matmul.etch
fn mul(a: [n][n]int, b: [n][n]int, n: int) -> int {
    let acc = 0;
    for i in 0..n {
        let ra = a[i];
        for j in 0..n {
            let s = 0;
            for k in 0..n { s = s + ra[k] * b[k][j]; }
            acc = (acc * 31 + s) % 1000000007;
        }
    }
    return acc;
}

tests/perf-clang-parity.sh compares each workload with the same algorithm written in C and compiled with clang -O2. The outputs must be identical, and Etch's time must stay within a fixed multiple of clang's. Latest figures, as Etch time divided by clang time:

WorkloadWhat it doesEtch / clang
getterreads a derived value 200,000 times; its input changes 200 times0.019
str_scansums the bytes of a string0.19
array_sortinsertion sort1.1
str_buildconcatenation, toString, split1.3
closure_callsa fold calling a closure1.5
int_looparithmetic and modulo in a loop1.6
str_parsea state machine over bytes1.7
map_opsstring-keyed map inserts and lookups1.8
rec_callsrecursive fib1.9
struct_stepupdates fields in an array of structs1.9
match_vma stack machine matching on an enum1.9
write_heavywrites a derived value's input 20,000 times, reads it 20 times2.0
matmulthe matrix multiply above2.6

Both versions are compiled for arm64 and run under qemu-user on x86_64 Linux; minimum of 5 runs, 2026-09-27. Emulation changes some ratios compared with native hardware. The C version of getter recomputes the value on every read, which is where the difference comes from. Details are in RFC 025 §6.1.

Status

Etch is a research project. The compiler implements part of the specification.

Implemented

  • A self-hosted compiler of about 149,000 lines of Etch. Built from its own source, it reproduces itself byte for byte.
  • An interpreter, and an arm64 backend that writes Mach-O object files directly; only the system linker runs after it.
  • x86_64 (ELF, COFF, Mach-O) and wasm32 emitters, covered by the test suite but not yet selectable targets. What each backend supports is listed in docs/native-subset.md.
  • A test corpus of 1,519 programs, of which 861 must be rejected.

Specified but not implemented

  • The checked profile and proof debt (RFC 001, RFC 007).
  • Reactive read sets that follow the branch taken, and keyed collections updated by deltas (RFC 017). Of the convergence proofs for reactive cycles, only the min/max form above is implemented, for one effect or a ring of effects.
  • Outcomes, time and retries for external calls (RFC 011).
  • A portable seed, so bootstrapping does not require an arm64 Mac (RFC 025 §10).

Known limitations

  • The compiler rejects some loops the specification allows, because its prover cannot find their measure.
  • Diagnostics are being moved to the structured error[E_…] form, and part of the corpus still expects the older wording.
  • The seed is an arm64 macOS binary. On Linux it runs through a loader, under qemu on x86_64.

Build

The compiler is built by a checked-in compiler binary, bootstrap/etch-seed, which runs directly on arm64 macOS. On Linux, tools/macho-run runs it and the programs it builds, using qemu-user on x86_64. Run the commands from the repository root.

# Linux only
sudo apt-get install qemu-user-static gcc-aarch64-linux-gnu   # x86_64 hosts
tools/macho-run/install
export PATH="$PWD/tools/macho-run/bin:$PATH"

# generated sources (node 22.6 or later)
git submodule update --init
export NODE_OPTIONS=--experimental-strip-types
node grammar/gen.ts
node tools/gen-builtin-tables.ts

# build the compiler (it prints its usage line at the end)
ETCH_NATIVE_BIN=$PWD/etch bootstrap/etch-seed src/main.etch --native

./etch file.etch             # check, then interpret
./etch file.etch --native    # check, compile and run

bash tests/run.sh            # all tests, about 10 minutes

Building an unchanged tree reproduces the seed byte for byte. HANDOVER.md describes how to update the seed and how to check a performance change.

Specification

The language is defined by the RFCs in docs/. They describe the intended language, including the parts listed above as not yet implemented. RFC 001 states the invariants the others build on.

  1. 001Language Constitution
  2. 002Types, Values, Identity, and Aliasing
  3. 003Semantic Identity, Locations, Regions, and Names
  4. 004Canonical Semantic Graph and Semantic IR
  5. 005Effects and Capabilities
  6. 007Evidence, Obligations, and Admission
  7. 011The External World — Outcomes, Time, Retry, and Events
  8. 017Reactivity and Incremental Computation
  9. 019Resources and Cost
  10. 024Transformations and Semantic Compatibility
  11. 025Backends, Runtime, and Bootstrap
  12. 027Machine Authors, Intent, and Diagnostics

12 RFCs.