Build an Actor-Based Key-Value Store
Start with ordinary functions, then add actor calls, supervision, wire schemas, and a same-process key-value store.
Save the first three steps as separate programs using the filenames shown; each has its own main. Steps 4 and 5 show declarations to study, rather than programs to run. Step 6 builds one complete main.hew file. If you already program, you can begin with Step 2: Actors & Messages or follow the short interactive actor route. For the ideas behind the syntax, keep the core concepts map nearby.
Step 1: Hello World
Every Hew program starts with a main function. Let's begin with the basics: bindings, types, and functions.
fn main() {
let greeting = "Hello, Hew!";
println(greeting);
var count = 0;
count = count + 1;
let pi: f64 = 3.14159;
let active: bool = true;
println(f"count={count}, pi={pi}");
}let creates an immutable binding; var creates a mutable one. Hew's core types are lowercase: i64, f64, string, and bool. Integer literals default to i64, float literals to f64.
Save the complete block as hello.hew, then check and run it:
hew check hello.hew
hew run hello.hewExpected output:
Hello, Hew!
count=1, pi=3.14159Try recovering from a diagnostic: change println(greeting) to println(greting) and run hew check hello.hew. Read the undefined-variable diagnostic and its source line, correct the spelling, then check and run again.
Functions can take parameters and return values. Save this next program separately as functions.hew:
fn add(a: i64, b: i64) -> i64 {
a + b
}
fn greet(name: string) -> string {
f"Hello, {name}!"
}
fn main() {
let sum = add(3, 4);
println(sum);
let msg = greet("world");
println(msg);
}Run this file with hew run functions.hew; it prints 7 and Hello, world! on separate lines. Now move on to actors.
Step 2: Actors & Messages
An actor is an isolated unit of state with a mailbox. Actors never share mutable state — they communicate exclusively through messages. This gives you compile-time data-race freedom without locks.
Define an actor with the actor keyword. Message handlers are marked with receive fn; shared helpers live in top-level free functions. Callers invoke handlers as direct method calls on the actor handle — no .send() or .ask() needed. Use var for fields a handler mutates (give a default) and reference state by bare name. Each handler takes zero or one argument — bundle multiple values into a struct.
actor Counter {
var count: i64 = 0;
// This handler returns (), wrapped in the call's Result
receive fn increment() {
count = count + 1;
}
// Arguments are passed like an ordinary method call
receive fn add(n: i64) {
count = count + n;
}
// The handler's i64 becomes the Ok value
receive fn get_count() -> i64 {
count
}
}
fn main() {
let counter = spawn Counter { count: 0 };
// Calls wait for the handler and report whether it completed
counter.increment().expect("actor stopped");
counter.increment().expect("actor stopped");
counter.add(10).expect("actor stopped");
// A value-returning handler uses the same blocking call shape
let n = counter.get_count();
match n {
.Ok(v) => println(f"count = {v}"),
.Err(_) => println("counter unavailable"),
}
}spawn creates a new actor and returns a Counter; fields are supplied by name in braces; omit the braces when no fields are needed. A call such as counter.increment() waits for the handler and returns its outcome. Each actor processes one message at a time, so there are never data races on count.
Save the complete program as counter.hew and run hew run counter.hew. It prints count = 12.
Step 3: Request-Reply Patterns
Every ordinary actor call waits for completion. A receive fn with a return type carries that value in its reply. The call yields Result<R, ActorError<E, C>> — match the Ok/Err arms. Handler parameters are written like ordinary function parameters.
type ComputeReq { op: string; a: i64; b: i64; }
actor Calculator {
var ops: i64 = 0;
receive fn compute(req: ComputeReq) -> i64 {
ops = ops + 1;
if req.op == "add" { req.a + req.b }
else if req.op == "mul" { req.a * req.b }
else if req.op == "div" { req.a / req.b }
else { 0 }
}
}
fn main() {
let calc = spawn Calculator { ops: 0 };
let sum = calc.compute(ComputeReq { op: "add", a: 10, b: 20 });
match sum { .Ok(v) => println(f"Sum: {v}"), .Err(_) => println("actor call failed") }
let product = calc.compute(ComputeReq { op: "mul", a: 3, b: 7 });
match product { .Ok(v) => println(f"Product: {v}"), .Err(_) => println("actor call failed") }
}The reply value is the trailing expression of the receive fn. Under the hood the message is enqueued, processed, and the reply returned — but the call always yields a Result<R, ActorError<E, C>>, never a bare R, so a failed call is observable. For ordinary in-function error handling, Hew provides Result<T, E> with the ? operator.
Run hew run calculator.hew to print Sum: 30 and Product: 21.
Step 4: Supervision & Fault Tolerance
A supervisor owns a set of child actors and decides which failures lead to a restart. The policy is written beside the child declarations, including a time-bounded restart budget so repeated crashes can be escalated instead of retried forever.
actor Worker {
let id: i64;
receive fn process(job: i64) {
println(f"Worker {id} processing job {job}");
}
}
supervisor WorkerPool {
strategy: one_for_one;
intensity: 5 within 60s;
child w1: Worker { id: 1 } restart: permanent;
child w2: Worker { id: 2 } restart: permanent;
child w3: Worker { id: 3 } restart: transient;
}Key concepts:
- strategy: one_for_one: only the crashed child is restarted — siblings keep running.
- restart: permanent: always restart, regardless of exit reason.
- restart: transient: restart only on abnormal exit (crashes), not on normal shutdown.
- restart: temporary: never restart — once stopped, it's gone.
- intensity: N within <window>: if the supervisor exceeds N restarts within the window, the failure escalates to the parent supervisor.
In this pool, one_for_one restarts only the child that failed. The two permanent children are restarted after any exit, while the transient child is restarted only after an abnormal exit. A sixth restart within sixty seconds exceeds the declared budget and escalates the failure.
Step 5: Wire Types
The key-value store in the next step stays in one process, so it uses ordinary structs. When a message does cross a serialization boundary, #[wire] type gives each field a stable numeric tag and records which additions are optional.
#[wire]
type PutRequest {
key: string @1;
value: string @2;
ttl: Option<u64> @3 optional;
}
#[wire]
type GetRequest {
key: string @1;
}
#[wire]
type GetResponse {
found: bool @1;
value: Option<string> @2 optional;
}Schema evolution rules:
- Never reuse a tag number — deleted fields should have their tags reserved.
- New fields must be optional (
optional) so old readers can still decode. - Encoding is automatic — Hew generates a compact binary encoding (MessagePack) from wire definitions, plus JSON/YAML helpers.
- Use
hew wire check --against <baseline.hew> <schema.hew>in CI to catch breaking changes.
Wire types implement Send and can be passed to actors. The definitions above are a protocol example; the final store does not use them because its two actors communicate inside one process.
Step 6: Build the Actor-Based Key-Value Store
Both actors in this example run in the same process. KVBackend owns the HashMap; KVFrontend holds its typed actor handle and forwards puts and gets. PutMsg keeps the two put arguments together as one value.
Work in a single main.hew file. Each block below includes the earlier definitions and replaces the previous contents; do not concatenate them or split them into the displayed actor sections. The final complete program is the runnable checkpoint.
Message protocol
type PutMsg {
key: string;
value: string;
}
type GetResult {
found: bool;
value: string;
}Backend actor
type PutMsg {
key: string;
value: string;
}
type GetResult {
found: bool;
value: string;
}
actor KVBackend {
var store: HashMap<string, string> = HashMap.new();
receive fn put(msg: PutMsg) {
store.insert(msg.key, msg.value);
}
receive fn get(key: string) -> GetResult {
match store.get(key) {
.Some(val) => GetResult { found: true, value: val },
.None => GetResult { found: false, value: "" },
}
}
}Frontend actor
type PutMsg {
key: string;
value: string;
}
type GetResult {
found: bool;
value: string;
}
actor KVBackend {
var store: HashMap<string, string> = HashMap.new();
receive fn put(msg: PutMsg) { store.insert(msg.key, msg.value); }
receive fn get(key: string) -> GetResult {
match store.get(key) {
.Some(val) => GetResult { found: true, value: val },
.None => GetResult { found: false, value: "" },
}
}
}
actor KVFrontend {
let backend: KVBackend;
receive fn handle_put(msg: PutMsg) {
backend.put(msg).expect("actor stopped");
}
// Actor calls wrap this reply as Result<GetResult, ActorError> for the caller.
receive fn handle_get(key: string) -> GetResult {
match backend.get(key) {
.Ok(r) => r,
.Err(_) => GetResult { found: false, value: "" },
}
}
}Putting it all together
Spawn the backend first, pass its typed handle to the frontend, and drive the frontend. The backend owns its map; callers communicate through handlers.
type PutMsg {
key: string;
value: string;
}
type GetResult {
found: bool;
value: string;
}
actor KVBackend {
var store: HashMap<string, string> = HashMap.new();
receive fn put(msg: PutMsg) { store.insert(msg.key, msg.value); }
receive fn get(key: string) -> GetResult {
match store.get(key) {
.Some(val) => GetResult { found: true, value: val },
.None => GetResult { found: false, value: "" },
}
}
}
actor KVFrontend {
let backend: KVBackend;
receive fn handle_put(msg: PutMsg) { backend.put(msg).expect("actor stopped"); }
receive fn handle_get(key: string) -> GetResult {
match backend.get(key) {
.Ok(r) => r,
.Err(_) => GetResult { found: false, value: "" },
}
}
}
fn main() {
let backend = spawn KVBackend { store: HashMap.new() };
let frontend = spawn KVFrontend { backend: backend };
// Store data; each call waits for the handler to finish
frontend.handle_put(PutMsg { key: "lang", value: "hew" }).expect("actor stopped");
frontend.handle_put(PutMsg { key: "version", value: "0.5" }).expect("actor stopped");
// Retrieve data — the call yields Result<GetResult, ActorError>; match once
match frontend.handle_get("lang") {
.Ok(r) => {
if r.found {
println(f"Found: {r.value}")
} else {
println("not found")
}
}
.Err(_) => println("actor call failed"),
}
}Replace main.hew with the complete program above, then check and run it:
hew check main.hew
hew run main.hewExpected output:
Found: hewThe backend owns the map. The frontend forwards requests to it, and both actors run in the same process. The frontend waits for the backend’s reply before returning its own result. Try adding a delete operation and passing it from the frontend to the backend.
As a further exercise, put the actors under a supervisor; crossing a network boundary would also require wire types and transport.
Next steps
- Core Concepts — ownership, isolation, lifecycle, and task lifetime explained progressively
- Actor Model — deep dive into message passing, lambda actors, and structured concurrency
- Type System — generics, capabilities, and pattern matching
- Standard Library — built-in types and IO