From Go
Go and Khora agree about the shape of the artifact: one native executable, no VM beside it, fast start, and an operational story you can hold in your head. They disagree about how much of a service’s behavior belongs in the type. Four places where a Go habit needs adjusting, with the Khora each of them becomes.
Errors: the second return value moves into the type
Section titled “Errors: the second return value moves into the type”Go returns failure as a value, by convention, and the compiler is happy if you ignore it:
user, err := loadUser(id)if err != nil { return Response{}, err}Khora puts the failure channel in the signature. The failure type is an ordinary ADT:
pub type UserError = | NotFound(id: Id) | Unavailable(reason: String);and the function says it uses the channel:
fn load_user(id: Id) -> User raises UserError{ // ...}The if err != nil becomes a postfix ! at the call, which propagates:
Id -> User raises UserErrorThree consequences a Go reader should expect:
!is not?and it is notpanic. It says this call may fail and I am letting the failure out of here, which is only legal if the enclosing function’s ownraisesrow admits it. A missing!is a compile error, not a dropped error.raisescomposes with+—raises UserError + HttpError— rather than collapsing to oneerrorinterface, so the arms of acatchare checked for exhaustiveness the way amatchis.- There is no
errors.Is/errors.Asunwrapping ceremony, because nothing was wrapped into an interface to begin with. Failures hascatchandattempt, which are the two ways back out.
The habit to drop: returning a zero value beside an error. A Khora function that
fails does not also produce an answer, so there is no zero Response{} to
invent and no caller reading one by mistake.
Dependencies: from a struct field to a with row
Section titled “Dependencies: from a struct field to a with row”Go passes a *sql.DB, a logger and a clock down through constructors, or
reaches for a package-level variable. Khora makes the requirement part of the
type, and it is discharged once, at the boundary:
with { config: env_config(), scope: Scope::root(), db: postgres_db()!, store: sql_store(),} { run_server()!}Everything inside that block can call functions declaring with { db: Db }
without being handed a db argument, and a function that does not declare it
cannot reach one. That is the part with no Go analog: the absence of ambient
authority is checked, so a helper five layers down cannot quietly open a socket.
The habit to drop: the context.Context first parameter. It is carrying three
different things — cancellation, deadlines and request-scoped values — and each
of them has its own home here. Values are capabilities in the with row.
Cancellation belongs to the nursery, below. Deadlines are the piece that does
not exist yet: there is no timeout, no race and no select, and
Concurrency says so plainly rather than leaving
you to find out.
Concurrency: go f() always has an owner
Section titled “Concurrency: go f() always has an owner”The Go loop that starts work and hopes:
for _, job := range jobs { go handle(job)}has no answer to who waits for these, what happens when one panics, or
what stops them. A sync.WaitGroup and a context are the conventional
answers and neither is enforced. In Khora a fiber is adopted by a nursery, and
the nursery is a scope that does not return until its children are done:
module main;
import std::core::{ChildFailed, Fiber, Nursery, bounded_nursery, print};
fn handle(job: Int) -> () { print("processing job ${job}");}
fn launch_jobs() -> () with { nursery: Nursery }{ let mut next = 0;
while next < 1000 { let job = next;
nursery.adopt( Fiber::spawn(fn () => handle(job)) );
next = next + 1; }}
pub fn main() raises ChildFailed { bounded_nursery(64, launch_jobs)!}bounded_nursery(64, ...) is also the WaitGroup plus the semaphore Go
programs write by hand: adoption blocks once the limit is live, so the producer
slows at the boundary where it makes work instead of filling a queue. See
Bound concurrent work, including the
off-by-one to subtract when the limit is a real resource.
Two of those three questions are answered today. Every child is waited for
and a failure is always reported as ChildFailed. The first failure cancels
the siblings still running when the nursery sees it, and it sees failures in
adoption order: a failing child adopted first stops all its siblings within
milliseconds, one adopted last is seen only after the others have finished and
stops none. So work that is expensive, holds a resource, or has an effect
outside the process should check a Shared flag itself rather than expect the
group to collapse promptly. Known
limitations
has the measurements. A Go reader coming off errgroup, where the first error
does cancel the group’s context at once, should not assume the same here.
Cancellation arrives without a ctx.Done() channel to select on, and without
a raises row either: a canceled fiber stops at its next loop back-edge,
blocking call, or call to a function that has one, in any function. That is
what makes an ordinary polling worker stoppable with nothing in it that looks
like a cancellation check:
fn reaper() -> () with { clock: Clock } { loop { clock.sleep(1000); sweep(); }}The habit to drop: a bare go. There isn’t one — Fiber::spawn produces a
handle, and you either join it for its answer or adopt it into a nursery
that will.
Memory: reference counting instead of a tracing collector
Section titled “Memory: reference counting instead of a tracing collector”Go’s collector gives you no annotations and a tail. Khora’s answer is Perceus reference counting with compiler ownership and reuse analysis: also no annotations, no borrow checker, and no collector thread — the counts are inserted at compile time and a uniquely-owned value is reused in place rather than freed and reallocated.
What that changes operationally is the shape of the latency curve and the resident set rather than the code you write. The Performance page has the measured numbers, the ladder they were taken on, and what is explicitly not measured; it is the page to read before quoting anything.
The one place a Go programmer gets less: a reference cycle leaks. A
tracing collector is what Khora exists not to have, so there is no cycle
collector, and weak references — the usual way to break one — do not exist yet
either. A cycle needs a mut field deliberately pointing back at something that
already reaches it, so it is not accidental; it is also not caught. The leak is
bounded and quiet rather than unsound: nothing is freed early and nothing is
read after free, the memory is simply never returned.
What stays familiar
Section titled “What stays familiar”Direct-looking code with no monadic plumbing, one binary to ship, a standard
library that covers HTTP and TLS, structured logging as JSON on standard error,
and a small number of runtime concepts visible in application code. khora fmt
is gofmt’s idea — canonical formatting, not a style argument — and khora test . compiles and runs the package’s tests one fiber each.
The thing that is genuinely missing rather than different: there is no package
registry, so a dependency is a git URL, and khora install fetches and locks
what khora.toml declares. Modules and packages
is the page for that.
Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource