Learn Hare

A small, stable systems language with manual memory and a tidy standard library.

Setup, the Toolchain, and Your First Program

Install the harec/QBE/hare toolchain, understand modules, and run hello world.

Setup, the Toolchain, and Your First Program

Hare is a small, statically typed, systems programming language created by Drew DeVault (the founder of SourceHut) and a group of contributors, announced publicly on 25 April 2022. Its goals are summed up in three words: simple, stable, robust. It is most similar to C - you get pointers, manual memory, and direct access to the machine and the C ABI - but it modernizes the rough edges with slices, tagged unions, defer, and errors-as-values. This track takes you from a first program through Hare's type system, its fully manual memory model, and its distinctive error handling.

Installing the toolchain

Hare deliberately keeps its toolchain compact. Instead of depending on LLVM, it compiles through QBE, a tiny optimizing backend by Quentin Carbonneaux that targets x86_64, aarch64, and riscv64. The toolchain is three pieces:

  • harec - the compiler (Hare source to QBE intermediate code)
  • qbe - the backend (QBE to assembly)
  • hare - the build driver you actually run day to day

Hare targets Linux and the BSDs by design and does not support Windows or macOS, reflecting the project's free-software commitment. Once installed, check the driver:

$ hare version
hare 0.24.2

Source files end in .ha.

Your first program

Create main.ha:

use fmt;

export fn main() void = {
	fmt::println("Hello, Hare!")!;
};

Several things to notice:

  • use fmt; imports the formatting module from the standard library, much like #include in C but namespaced.
  • Execution starts at a function named main that is marked export (so the runtime can see it) and returns void.
  • fmt:: is a namespace qualifier: println lives in module fmt. The :: separates module from member.
  • The trailing ! is not punctuation for show - println can fail (writing to a closed stream, say), and ! says "I assert this will not fail; if it does, abort." You will meet ! properly in the error-handling lesson; for now read it as "and I promise this works."
  • Function bodies are expressions: export fn main() void = { ... }; assigns a block to the function. Note the = and the trailing semicolon after the closing brace.

Building and running

The hare driver compiles and runs for you:

$ hare run main.ha
Hello, Hare!

$ hare build -o hello main.ha
$ ./hello
Hello, Hare!

hare run builds to a temporary binary and executes it - handy while iterating. hare build produces a standalone native executable. There is no separate package manager or build-config language to learn for small programs; the driver discovers dependencies from your use statements.

Modules

Anything bigger than one file becomes a module: a directory of .ha files that are compiled together as a unit. The directory name is the module name, and Hare searches HAREPATH (which includes the standard library) to resolve a use. We return to modules and visibility (export) in the final lesson; for now, know that the standard library you import - fmt, io, os, strings, bufio - is just a tree of such modules.

Where Hare sits among systems languages

Every language in this collection rejects a garbage collector but draws the manual-memory line differently. It helps to hold the whole map in mind as you learn Hare:

Language Memory model in one line
C malloc/free by hand; raw pointers, no help.
C++ RAII - destructors free at scope exit; unique_ptr/shared_ptr automate it.
HolyC TempleOS dialect: MAlloc/Free from a per-task heap, ring 0, no protection.
Zig No hidden allocations; pass an explicit Allocator; defer/errdefer free.
Hare Built-in alloc/free; defer schedules cleanup; tiny runtime, no RAII.
Odin new/free routed through an implicit context.allocator; defer + arenas.
Forth Rawest: HERE/ALLOT bump a pointer, @/! read/write cells.

Hare's position is close to Zig and C: explicit allocation, no GC, but cleanup made ergonomic with defer. Keep this table handy - we contrast Hare with each cousin as the relevant features come up.

Reference

Next: Hare's type system, bindings, and control flow.

Types, Bindings, and Control Flow

Explicit-width numbers, no implicit conversions, expression-oriented if, for, switch and match.

Types, Bindings, and Control Flow

Hare is statically and strongly typed: every value has a type fixed at compile time, and unlike C it will not silently convert between numeric types behind your back. You either let the compiler infer a binding's type or state it explicitly, and you make conversions visible with a cast.

Bindings: let and const

You introduce a variable with let (mutable) or const (immutable). The type can be inferred from the initializer or written after a colon:

let x = 42;          // inferred as int
let count: u32 = 0;  // explicit type
const pi = 3.14159;  // immutable f64

x = 10;              // ok, x is mutable
// pi = 3.0;         // error: pi is const

const here means the binding cannot be reassigned; it is the everyday choice for values you do not intend to change.

Numeric types are explicit-width

Hare's integer and float types name their size directly - there is no fuzzy "natural int whose width depends on the platform" the way C has:

let a: i8  = -1;    // signed 8-bit
let b: u8  = 255;   // unsigned 8-bit
let c: i32 = 1000;  // signed 32-bit
let d: u64 = 1;     // unsigned 64-bit
let e: f32 = 1.5;   // 32-bit float
let f: f64 = 1.5;   // 64-bit float

There are also int and uint (platform word-sized), size (an unsigned type big enough to index any object, like C's size_t), uintptr, and bool, void, plus the rune/string types covered later.

No implicit numeric conversions

This is a deliberate departure from C. Hare will not quietly widen or narrow numbers; mixing types is a compile error until you cast with the : type cast expression:

let n: i32 = 5;
let m: i64 = 2;
// let bad = n + m;        // error: type mismatch i32 vs i64
let good = n: i64 + m;     // explicit cast makes intent visible

let big: i64 = 300;
let small = big: i8;       // explicit, possibly-truncating cast

Because conversions are explicit, the class of "I accidentally compared signed to unsigned" bugs that plague C simply does not type-check in Hare.

Operators

The usual arithmetic, comparison, logical, and bitwise operators are present:

let flags: u32 = 0;
let with_bit = flags | (1u32 << 3);   // set bit 3
let cleared  = with_bit & ~(1u32 << 3); // clear bit 3
let half = 17 / 2;                     // 8 (integer division)
let rem  = 17 % 5;                     // 2
let yes  = (3 < 4) && (1 == 1);        // true

Control flow: if is an expression

if/else works as a statement and, importantly, as an expression that yields a value:

let n = 7;
const label = if (n % 2 == 0) "even" else "odd";  // expression form

Loops come in three forms - for, the C-style three-clause loop, and the bare infinite loop:

// C-style for loop
for (let i = 0z; i < 5; i += 1) {
	fmt::printfln("i = {}", i)!;
};

// condition-only loop
let n = 3;
for (n > 0) {
	n -= 1;
};

// infinite loop with explicit break
for (true) {
	if (done()) break;
};

Note 0z - the z suffix makes a size-typed literal, the right index type for loops over collections. break and continue work as expected, and loops can be labeled (:outer) to break out of nesting.

match and switch

switch branches on a value; match branches on the type of a tagged union (the subject of the next lesson). Both are expressions and both must be exhaustive - you handle every case or provide a default, which the compiler checks:

const grade = 'B';
const desc = switch (grade) {
case 'A' => yield "excellent";
case 'B' => yield "good";
case     => yield "other";    // default case (empty label)
};

yield produces the value of a branch (and can yield from any block). Exhaustiveness checking is a real safety win over C's switch, which silently falls through and needs no default.

Reference

Next: tagged unions, Hare's standout type-system feature, plus slices and strings.

Arrays, Slices, Strings, Structs, and Tagged Unions

Length-carrying slices, UTF-8 strings, structs, and first-class exhaustively-checked tagged unions.

Arrays, Slices, Strings, Structs, and Tagged Unions

Now we build aggregate data. Two Hare ideas stand out against C: slices, which bundle a pointer with a length so buffer sizes travel with the data, and tagged unions, first-class sum types that know which variant they hold. Together they remove whole categories of C bugs.

Arrays

An array is a fixed-size, contiguous sequence whose length is part of its type:

let nums: [4]int = [10, 20, 30, 40];
let zeros: [8]u8 = [0...];   // [0...] fills the whole array with 0

fmt::printfln("first = {}", nums[0])!;
fmt::printfln("len   = {}", len(nums))!;   // 4, known from the type

The built-in len() reports the length. Unlike C, Hare arrays do not silently decay to bare pointers, so the length is never lost.

Slices: a pointer plus a length

A slice, written []T, is the workhorse for variable-length sequences. It is a fat pointer: a pointer to the elements and a length, packaged together. You can slice an array or another slice with [start..end]:

let arr: [5]int = [1, 2, 3, 4, 5];
let middle: []int = arr[1..4];   // a slice viewing elements 1,2,3

fmt::printfln("len {}, first {}", len(middle), middle[0])!;  // len 3, first 2

Because the length rides along with the pointer, a function that takes []int always knows how many elements it has - no separate size_t n parameter, and no chance of the two falling out of sync:

fn sum(xs: []int) int = {
	let total = 0;
	for (let i = 0z; i < len(xs); i += 1) {
		total += xs[i];
	};
	return total;
};

This is exactly the C buffer-length problem solved at the type level. (Slices that own heap memory are created with alloc and freed with free - see the memory lesson.) Index access is bounds-checked: an out-of-range index aborts rather than silently corrupting memory the way C does.

Strings and runes

Hare has a real str type. Strings are UTF-8, and a rune is a single Unicode code point (a 32-bit value):

const greeting: str = "Hello, Hare";
fmt::printfln("bytes = {}", len(greeting))!;   // byte length

const r: rune = 'A';

To iterate over characters you go through the strings module's iterators rather than indexing bytes directly, because a UTF-8 code point may span several bytes. String literals are immutable; building strings dynamically uses strings::concat, fmt::asprintf, or a strio/memio buffer - all of which allocate, and so are your responsibility to free.

Structs

A struct groups named fields. You build one with field-named syntax and read fields with the dot operator:

type point = struct {
	x: int,
	y: int,
};

let p = point { x = 3, y = 4 };
fmt::printfln("({}, {})", p.x, p.y)!;

p.x = 10;   // mutate a field (p must be a let binding)

type name = ...; declares a named type. Structs are value types: assigning one copies it.

Tagged unions: the distinctive type

Hare's signature type-system feature is the tagged union - a value that is one of several types, with the language tracking which. You write the type as the alternatives separated by |:

type number = (i32 | f64);     // either an i32 or an f64

let n: number = 42i32;         // currently holds an i32

A tagged union stores a hidden tag plus enough space for its largest member. Two operators work with it: is tests the active type, and as extracts the value of a known-active type:

if (n is i32) {
	let v = n as i32;
	fmt::printfln("int: {}", v)!;
};

The idiomatic way to handle every case is match, which is checked for exhaustiveness - leave a variant out and the program will not compile:

const describe = match (n) {
case let i: i32 => yield "it was an integer";
case let f: f64 => yield "it was a float";
};

This is a world apart from C's manual tagged union, where you keep a separate enum tag field by hand and nothing stops you from reading the wrong member. In Hare the tag is part of the value and the compiler enforces that you account for every possibility. As the next two lessons show, this same machinery is how Hare models optional values ((T | void)) and errors as values ((T | error)) - no exceptions required.

Reference

Next: the heart of Hare - manual memory with alloc, free, and defer.

Manual Memory Management: alloc, free, and defer

Stack vs heap, the alloc expression, owning slices, and defer-scheduled cleanup with no GC or RAII.

Manual Memory Management: alloc, free, and defer

This is the lesson that defines Hare. Like C, Hare has no garbage collector, no reference counting, and no hidden allocations - and unlike C++, no RAII destructors. You allocate heap memory yourself and you free it yourself. What Hare adds to make this bearable is defer: a way to schedule cleanup right next to the allocation, so you are far less likely to forget it.

Stack vs heap

Local bindings live on the stack: created when their scope is entered, released automatically when it exits. Use the heap when a value must outlive the function that made it, or when its size is only known at runtime.

fn f() void = {
	let x = 5;        // on the stack, gone when f returns
	let buf: [64]u8 = [0...];  // also on the stack
};

alloc: requesting heap memory

Hare allocates with the built-in alloc expression rather than a library call. alloc(value) puts value on the heap and returns a pointer to it (*T). You read and write through the pointer by dereferencing with the unary *:

let p: *int = alloc(42);   // one int on the heap, initialized to 42
fmt::printfln("{}", *p)!;  // dereference to read: 42
*p = 100;                  // write through the pointer
free(p);                   // give it back - YOUR responsibility

Note that alloc initializes what it returns - there is no uninitialized-memory hazard the way malloc leaves you with garbage bytes. When you link against libc, these allocations ultimately go through malloc/free, so the cost model is the familiar one.

Allocating slices

To get a heap-backed, owning slice, alloc an array-fill expression. The result is an []T that owns its storage, which you later free:

let xs: []int = alloc([0...], 10);  // 10 ints, all zero, on the heap
defer free(xs);                      // schedule the free now (see below)

for (let i = 0z; i < len(xs); i += 1) {
	xs[i] = i: int;
};

Slices can also grow: append(xs, value) and insert may reallocate the backing store. Because the slice carries its own length and capacity, you do not juggle a separate realloc dance the way C requires - but the slice is still owned memory you must free.

defer: cleanup that cannot be forgotten

The marquee ergonomic feature is defer. A deferred statement runs when the enclosing scope exits - by any path: normal fall-through, an early return, or a break. The idiom is to defer the cleanup on the line after you acquire the resource:

fn process() void = {
	let buf: []u8 = alloc([0...], 1024);
	defer free(buf);          // runs no matter how we leave this scope

	if (something_failed()) {
		return;               // defer still frees buf here
	};

	use_buffer(buf);
};                            // and frees buf here on normal exit

Multiple defers in a scope run in reverse order (last deferred, first run), which matches the natural unwinding of nested resources:

let a = alloc(1);
defer free(a);
let b = alloc(2);
defer free(b);     // b is freed first, then a

defer is Hare's answer to the same problem C++ solves with RAII and Zig/Odin solve with their own defer: it keeps acquisition and release textually together so the cleanup is obvious and hard to miss. But note the key difference from C++: defer is explicit. Nothing runs automatically just because a value went out of scope; if you do not write a defer (or a manual free), the memory leaks.

The bugs you still own

Hare removes some of C's hazards - allocations are initialized, slices are bounds-checked, conversions are explicit - but the core ownership discipline is still on you. The classic manual-memory bugs remain possible:

  • Leak - you never free (or defer free) an allocation.
  • Double free - freeing the same pointer twice.
  • Use-after-free - dereferencing a pointer (or using a slice) after it was freed.
let p = alloc(1);
free(p);
// *p = 2;   // use-after-free: undefined behavior, just like C

The fix in practice is discipline plus the defer-on-the-next-line habit, which makes the common case correct by construction.

How Hare's model compares

All seven languages here are GC-free, but they free differently:

  • C - malloc/free by hand; uninitialized memory; no defer, no bounds checks.
  • C++ - RAII: destructors free automatically at scope exit; unique_ptr/shared_ptr.
  • HolyC - MAlloc/Free from a per-task heap in ring 0; no protection, no defer.
  • Zig - pass an explicit Allocator to anything that allocates; defer/errdefer free.
  • Hare - built-in alloc/free; explicit defer schedules cleanup; no allocator argument threaded through (it uses the libc allocator).
  • Odin - new/make/free go through an implicit context.allocator; defer + swappable arenas.
  • Forth - HERE/ALLOT bump a static pointer; optional ALLOCATE/FREE for the heap.

Hare sits between C and Zig: more ergonomic and safer than raw C (initialized allocs, fat-pointer slices, defer), but - unlike Zig - it does not make the allocator an explicit parameter, and - unlike C++ - it does not free anything for you automatically.

Reference

Next: Hare's error handling, where this same tagged-union and defer machinery shines.

Error Handling: Errors as Values, ! and ?

No exceptions: tagged-union results, ? to propagate, ! to assert, and defer for cleanup on every path.

Error Handling: Errors as Values, ! and ?

Hare has no exceptions and no panics-as-control-flow. Errors are ordinary values returned from functions, expressed with the same tagged-union machinery you already know. Two tiny operators - ! and ? - make handling them concise, and defer guarantees cleanup runs on the error path too. This is one of Hare's most distinctive and pleasant features.

Errors are values in a tagged union

A function that can fail returns a tagged union of "the success type" and "the error type(s)". By convention errors are members whose type ends in !error or are drawn from !-marked error types. A common shape is (T | error):

use errors;

// returns an i64 on success, or an error value on failure
fn parse_count(s: str) (i64 | errors::invalid) = {
	// ... parse and return either an i64 or errors::invalid ...
};

There is nothing magic here: the return type is just a tagged union, and the caller must deal with both possibilities. The compiler will not let you ignore the error case.

Handling with match

Because the result is a tagged union, you handle it with match, exhaustively:

const result = parse_count("42");
match (result) {
case let n: i64 =>
	fmt::printfln("got {}", n)!;
case let e: errors::invalid =>
	fmt::printfln("bad input")!;
};

This is explicit and clear, but writing a full match everywhere is verbose - so Hare gives you two operators for the common cases.

The ? operator: propagate the error

? says "if this is an error, return it from the current function; otherwise unwrap the success value." It is how you bubble failures up a call chain without boilerplate. The enclosing function's return type must be able to hold the error:

fn total(a: str, b: str) (i64 | errors::invalid) = {
	const x = parse_count(a)?;   // on error, return it from total
	const y = parse_count(b)?;   // same
	return x + y;                // only runs if both succeeded
};

If parse_count(a) yields an error, ? immediately returns that error from total, and y is never evaluated. This gives you Go-style explicit propagation with Rust-style brevity, but with no hidden control flow - it is just an early return.

The ! operator: assert it cannot fail

! says "I am certain this will not fail; if I am wrong, abort the program." Use it at the top level, in throwaway code, or where a failure is genuinely a bug rather than an expected condition. You have already seen it on println:

fmt::println("hello")!;          // if writing fails, abort
const n = parse_count("42")!;    // assert "42" parses; abort if not

! is honest: it is a loud, documented assertion, not a silent ignore. Compare with C, where the equivalent is forgetting to check errno or a return code - a silent bug. In Hare you must write the ! to opt out of handling, so unhandled errors are visible in the source.

Optionals use the same idea

"A value or nothing" is just a tagged union with void:

fn find(xs: []int, target: int) (size | void) = {
	for (let i = 0z; i < len(xs); i += 1) {
		if (xs[i] == target) return i;
	};
	return void;   // not found
};

match (find(xs, 3)) {
case let idx: size => fmt::printfln("at {}", idx)!;
case void          => fmt::println("not found")!;
};

There is no separate Option type to learn - optionals, errors, and variants are all the one tagged-union concept.

defer makes the error path safe

The reason ? is safe to sprinkle around is that it composes with defer. Because a deferred statement runs on every exit from a scope - including the early return that ? performs - your free/close/cleanup still happens when an error propagates:

fn read_thing() (str | io::error) = {
	const file = os::open("data.txt")?;  // may fail and return early
	defer io::close(file)!;              // still runs if a later ? returns

	const data = io::drain(file)?;       // if this fails, file is closed by defer
	return strings::fromutf8(data)!;
};

This pairing - errors as values propagated by ?, cleanup guaranteed by defer - is the core of writing robust Hare. There are no exceptions to unwind, no finally blocks, and no destructors firing implicitly; just two operators and a deferred statement, all of which you can see in the source.

How Hare compares

  • C - return codes and errno; nothing forces you to check; cleanup via goto cleanup; labels.
  • C++ - exceptions plus RAII, or std::expected; cleanup is automatic via destructors.
  • Zig - error unions and the try/catch operators; errdefer for error-only cleanup - the closest cousin to Hare.
  • Hare - tagged-union results, ? to propagate, ! to assert, defer for cleanup. No exceptions.
  • Odin - multiple return values and or_return/or_else; defer for cleanup.
  • Forth - manual: THROW/CATCH exception words over the stack.

Reference

Next: the standard library, modules, and calling C.

The Standard Library, Modules, and Calling C

A curated stdlib, module/export visibility, stream I/O with defer, and native C-ABI interop.

The Standard Library, Modules, and Calling C

Hare ships a tidy, batteries-included standard library - notably larger and more curated than C's - covering I/O, strings, data structures, hashing, crypto, networking, dates, and more, all as importable modules. This final lesson tours the essentials, shows how to structure a multi-file program, and covers Hare's strong C interoperability, the feature that makes it practical for real systems work.

Modules you will use constantly

You import a module with use and reach into it with :::

Module Provides
fmt formatted output: println, printfln, printf, asprintf
io streams: read, write, copy, drain, close
os files and the environment: open, create, args, exit
bufio buffered readers/writers and line scanning
strings string building, splitting, iteration, conversion
strconv parse numbers from strings and back
sort sorting slices
hash::*, crypto::* hashing and cryptography
use fmt;
use strings;

export fn main() void = {
	const parts = strings::split("a,b,c", ",");
	defer free(parts);                 // split allocates - free the result
	fmt::printfln("{} parts", len(parts))!;
};

Notice the standard-library functions follow the same memory contract as your own code: strings::split allocates, so you defer free(parts). The library never hides allocations from you.

Formatted output

fmt uses {} placeholders rather than C's %d/%s typed specifiers - the type is known statically, so you do not have to match a format letter to it:

let n = 7;
let pi = 3.14159;
fmt::printfln("n={} pi={}", n, pi)!;     // n=7 pi=3.14159
fmt::printfln("hex {:x}", 255)!;         // hex ff (formatting modifiers after a colon)

Because the placeholder carries no type, the printf-family "format string does not match the argument" class of C bugs cannot occur.

Reading a file

I/O is stream-based through io, and resources are closed with defer:

use bufio;
use io;
use os;
use strings;

export fn main() void = {
	const file = os::open("data.txt")!;   // assert it opens (or handle the error)
	defer io::close(file)!;               // always closed on exit

	const scan = bufio::newscanner(file);
	defer bufio::finish(&scan);
	for (true) {
		const line = match (bufio::scan_line(&scan)) {
		case let s: const str => yield s;
		case io::EOF           => break;
		case let e: io::error  => os::exit(1);
		};
		fmt::println(line)!;
	};
};

An open file is a resource just like a heap allocation: io::close is to os::open what free is to alloc, and defer ties them together.

Multi-file programs and visibility

A module is a directory of .ha files compiled as a unit; the directory name is the module name. Within a module, declarations are private by default and shared with export:

// in directory mathutil/  ->  module "mathutil"
// file: vec.ha
export fn add(a: int, b: int) int = a + b;   // visible to importers
fn helper() void = void;                       // private to the module
// in your main program
use mathutil;

export fn main() void = {
	fmt::printfln("{}", mathutil::add(2, 3))!;
};

hare build discovers the module from your use and the HAREPATH, so there is no separate build script for ordinary layouts. export is also how main is made visible to the runtime, which is why the entry point is always export fn main.

The distinctive feature: clean C interop

Hare is designed to live in the C world. It compiles to native code, uses the C ABI, and can call C library functions directly - which is what makes it viable for operating systems, drivers, compilers, and networking tools today, before its own ecosystem is large.

// declare an external C function and call it
@symbol("strlen") fn c_strlen(s: *const u8) size;

export fn main() void = {
	const cstr = "hello\0";                 // NUL-terminated for C
	const n = c_strlen(cstr: *const u8);
	fmt::printfln("len = {}", n)!;
};

The @symbol attribute binds a Hare declaration to a C symbol at link time; you then link the C library with the build. Because Hare speaks the C ABI natively, you can adopt it incrementally alongside an existing C codebase - the same pragmatic interop story that every systems language in this collection ultimately needs, since C remains the universal ABI.

Where to go next

You now have the full arc: the toolchain and modules; the strong, explicit type system; slices and tagged unions; manual memory with alloc/free and defer; errors as values with ? and !; and the standard library and C interop. Hare is deliberately small, so the best next step is to read the specification end to end (it is short by design) and build something concrete - a line filter, a tiny HTTP client, a file tool - leaning on defer to keep your memory and resource handling honest.

Reference