Pular para o conteúdo

Start here

A guided tour

Build a small concurrent metrics pipeline and meet every construct doing a real job.

Guided tour · 441 lines · 14 min read

Este conteúdo não está disponível em sua língua ainda.

A systems language that combines four bets which usually come separately, and never makes you pay for the ones you don’t summon.

You don’t need to know anything about Makoto to read this. We’ll build one thing, a small concurrent metrics ingestion pipeline, and grow it feature by feature, so every construct shows up doing an actual job instead of printing “hello world”.

Makoto is what you reach for when Go isn’t enough and Rust is too much. It combines four things that normally live in different languages:

  • BEAM resilience (Erlang): isolated processes, structural supervision, let it crash. A process that fails doesn’t corrupt the rest.
  • Go concurrency: spawn makes cheap processes; they talk over typed channels.
  • Rust’s types, without the lifetimes: sum types, generics, exhaustive matching, no borrow checker, and no 'a infecting every signature.
  • Zig’s comptime: metaprogramming in the language itself, arbitrary-width integers, first-class C FFI, IO with no function coloring.

And three things it deliberately leaves out:

  • No null. Absence is Optional[T], explicit in the type.
  • No void/unit. A function that returns nothing just has no -> T.
  • No async coloring. There is no async/await; the scheduler suspends a process on IO, and no function is marked.

The philosophy, in one line: consistent, not simple. You only pay for what you summon.

The name is 誠 (makoto, “truth”): the language refuses to hide where a guarantee stops.

1 · Values, types, and the absence of null

Section titled “1 · Values, types, and the absence of null”
count := 0 // immutable, type inferred (int). ':=' is the common case.
var total := 0 // 'var' = mutable
const MAX_BATCH := 4096 // compile-time constant
port: u16 = 8080 // explicit type, written 'name: T = v'
ratio: f64 = 0.75

Two rules that kill whole classes of bugs:

  • Immutable is the default. You write var only when you actually mutate. := (or : T =) declares; a bare = only assigns to a var.
  • There is no null. Absence is a type you can see:
last_error: Optional[string] = none // 'none' = absent
first := some("cpu") // 'some(x)' = present

There’s also no implicit zero-value and no silent discard: dropping a returned value on the floor is an error, so you write _ := expr to say you meant to ignore it.

Numbers go from i8/u64/f16/f64 up to arbitrary precision (BigInt, Decimal, BigFloat). Makoto targets scientific computing, so overflow and NaN/Inf/÷0 trap by default instead of silently corrupting a result. Durations are literals with no import: 5s, 300ms, 10ns.

fn severity(code: int) -> string {
return match code { // 'return match' distributes the return into the arms
200 | 201 | 204 => "ok" // or-pattern with '|'
404 | 410 => "gone"
_ => "other" // the default is always an explicit '_'
}
}

Makoto has no implicit return. A block does not evaluate to its last expression, and a match arm’s => does not deliver a value; the arm runs logic. To produce a value you write return (or return match ..., which distributes it). To choose between exactly two values, use the ternary:

label := ok ? "healthy" : "degraded"

One loop keyword, the header picks the shape:

loop { } // infinite
loop running { } // while
loop m in batch { } // for-each (collection, range, generator)
loop i in 0..MAX_BATCH { } // counted range ('..=' is inclusive)
fn sum(xs: []int) -> int {
var acc := 0
loop x in xs { acc += x }
return acc
}

break/continue carry no value (a loop delivers via return); a label targets an outer loop (outer: loop ... { break outer }).

3 · Composite types: one keyword, three shapes

Section titled “3 · Composite types: one keyword, three shapes”

There is no struct/enum/interface keyword. There’s decl, and the body says which of the three you’re declaring, because all three are the same act: declaring a contract.

decl Metric { name: string; value: i64 } // fields → product (struct)
decl Event { // variants → sum (enum); variants carry data
Ingest(Metric)
Flush
Shutdown(string) // carries a reason
}
decl Sink { fn write(m: Metric) -> error } // signatures → behavior (interface)

Methods aren’t inside the struct; they’re free functions with a receiver (Go’s model). Constructors are the same, with no name in the receiver:

fn (Counter) new() -> Counter { return Counter{ "", 0 } } // associated fn: Counter.new()
fn (c: *Counter) inc(by: i64) { c.value += by } // method: c.inc(1)

Sum types are matched by pattern, and the payload comes out named, only in the arm where you proved the shape:

fn handle(e: Event) {
match e {
Ingest(m) => record(m) // 'm' is the Metric, only here
Flush => flush_all()
Shutdown(why) => log("stopping: {{why}}") // 'why' is the string
}
}

match over a closed type is exhaustive and forbids _, so adding a variant later breaks the build until you handle it. That’s the safety net working as designed.

Reuse is by composition, not inheritance. The mechanism is @embeds, with promotion declared instead of inferred:

@embeds(Animal, methods) // Dog promotes Animal's methods; collisions resolve by explicit path
decl Dog { animal: Animal; name: string }

4 · Errors are values (no exceptions, no ?-magic)

Section titled “4 · Errors are values (no exceptions, no ?-magic)”

A failure has two independent questions, and Makoto gives one construct to each:

  • Result[T, E]: did it succeed? (a builtin enum { Ok(T), Err(E) })
  • error{...}: which failures exist? (a closed, structural error-set the compiler knows exactly)
fn parse(line: string) -> Result[Metric, error{Empty, BadFormat}] {
if line.len == 0 { return Err(error.Empty) }
// ...split "name value"...
return Ok(Metric{ "reqs"; 1 }) // struct literal: fields in order, separated by ';'
}

In the sequential case, a trailer handles the error while the Ok is bound by the assignment. There’s no ? operator, so you see what happens:

m := parse(line) catch |e| { return e } // propagate ('return e' sugar wraps to Err(e))

To branch by variant, use match |e| (each arm must leave; here we’re inside a loop, so continue):

m := parse(line) match |e| {
Empty => continue // skip blank lines
BadFormat => { log("bad line: {{line}}"); continue }
}

To recover with a value, match the whole Result (the fork form):

metric := match parse(line) {
Ok(m) => return m
Err(error.Empty) => return Metric{ "skipped"; 0 }
Err(error.BadFormat) => return Metric{ "rejected"; 0 }
}

Error-sets compose automatically: a function that calls two fallible functions unions their errors with the ... spread:

fn load(p: string) -> Result[Data, error{open..., parse...}] { // union of both error-sets
f := open(p) catch |e| { return e }
d := parse(f) catch |e| { return e }
return Ok(d)
}

And error{} (the empty set) is a type-level way to say a function never fails.

Slices and arrays are core; List/Map/Set live in the collections package (tier 0). A slice literal uses commas (it’s a value group):

lines := []string{ "cpu 12", "mem 40", "" }
loop l in lines {
io.println("got: {{l}}") // '{{ }}' is interpolation; anything Display-able goes in
}

Collections are values: binding copies them, and you share without a copy through a view or a *T pointer, so the cost is always explicit and there’s no hidden Rc. Strings are UTF-8, and the access unit is always spelled out (.bytes, .codepoints, .graphemes), so “length” is never ambiguous.

Interpolation uses the small Display interface (fn to_string() -> string): implement one method and your type drops into any "{{ }}".

Two brackets, two worlds: [...] is compile time, (...) is runtime.

fn first[T](xs: []T) -> Optional[T] { // [T] is a compile-time type parameter
if xs.len == 0 { return none }
return some(xs[0])
}

[T] monomorphizes (zero-cost, like Rust/Zig); an interface value dispatches through a vtable instead. Value-parameters go in the same brackets: Vec[T, n: usize] is a vector whose length is part of its type.

comptime runs pure Makoto in the compiler. It’s the same language as the rest of the program, fenced to be pure and total. This is how the stdlib derives serialize for any type, with no macros:

comptime match reflect(T).kind { // reflect(T) = the type, as a value
Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) }
Slice(_) | Array(_, _) => loop x in v { serialize(x, out) }
Int(_) | Float(_) | Bool => out.write(v.to_bytes())
// ...Enum, Optional, String...
}

7 · Processes and channels (the pipeline core)

Section titled “7 · Processes and channels (the pipeline core)”

The unit of concurrency is the process: isolated (its own heap), cheap, and preemptible even on a tight CPU loop. You make one with spawn (like Go’s go). Processes don’t share memory; they talk over typed channels, where the direction is from your point of view: <-T = “I send T”, ->T = “I receive T”.

fn ingester(out: Channel[<-Metric]) { // I send Metric
loop l in read_lines() {
m := parse(l) catch |e| { continue }
out <- m // async send
}
}
fn aggregator(in: Channel[->Metric]) { // I receive Metric
var total := 0
loop {
m := -> in catch |e| { break } // receive; senders all gone → stop
total += m.value
}
io.println("total ingested: {{total}}")
}
c := Channel.new() // the compiler checks the two ends are complementary at spawn
spawn ingester(c)
spawn aggregator(c)

No coloring: -> ch blocks only this process, and the scheduler runs everything else. For parallel IO you don’t await N futures; you spawn N processes and receive:

spawn fetch("https://a", ca)
spawn fetch("https://b", cb)
ra := -> ca // blocks only when necessary
rb := -> cb

Wait on several at once with the subjectless match (this is the “select”):

match {
m := -> metrics => record(m)
_ := -> control => rotate_file() // '_ :=' discards the received value
} timeout(1s) {
heartbeat() // runs if nothing arrived in time; timeout(0) = poll
}

The synchronous request-reply <-> requires a timeout: two processes replying to each other would deadlock, so the deadline is the structural way out:

reply := server <-> request timeout(5s) catch |e| { return e } // e: Timeout | ProcessDown

When a process dies, its defers run, its memory is freed, and an error describing the death is produced. That error is what an observer gets: error-as-value, applied to process death. A monitor is the catch of a supervised spawn:

@supervisor(max_restarts: 3, window: 10s)
spawn worker(batch) match |e| {
Normal => log("batch done")
Crashed(m) => restart()
Timeout => escalate()
}

The three classic strategies emerge from the structure, with no behaviour declared on the side:

// rest_for_one: an ordered pipeline (the only one that needs the annotation)
@supervisor(strategy: rest_for_one)
spawn {
db_connection() // dies → restarts all three
db_writer() // dies → restarts writer + reader
db_reader() // dies → restarts only reader
} catch |e| {
notify_ops("pipeline exhausted its restarts: {{e}}")
}

Processes in the same spawn-block share fate because the structure says so. The supervision tree is the code, with no invisible link/monitor primitives and no cascading death you can’t see.

9 · Memory: colorless by default, metal when you ask

Section titled “9 · Memory: colorless by default, metal when you ask”

Allocation is colorless: every object carries an implicit pointer to its MemoryManager, so with the default GC you just write code:

fn build() -> Report {
r := Report.new() // allocated through the context's @mm (GC by default)
r.add(line)
return r // no manual free; the GC handles it
}

When you want control, @mm picks the strategy for a scope: gc (default), arena (frees in a block), none (manual, C-style), or c (memory from C):

fn handle(req: Request) -> Response {
arena := Arena.new()
defer arena.free_all() // everything below dies all at once
@mm(arena) {
scratch := parse_body(req) // allocated in the arena
return build_response(scratch) @promote(gc, deep) // copy the result out to the GC
}
}

Because heaps are isolated, moving data between processes is explicit. @transfer moves and invalidates the source; a plain pass copies:

@mm(arena) spawn producer(data @transfer) // moves; 'data' is invalid here afterward

OOM is a process event rather than a per-call error (a per-call error would be coloring): the allocation that fails kills the process, and the supervisor handles it. The recoverable, budgeted case drops down to mem.alloc, which returns a Result:

@limit(16mb)
spawn worker(data) // blows the ceiling → an OOM error becomes the supervisor's |e|

The mental model has no borrow checker. At the transfer points, the compiler checks one simple thing: has this variable already been transferred? Either the data is yours, or you moved it.

C comes in through @cimport, which runs at comptime, compiles the header, and hands you a module under the c namespace. You redeclare no prototypes:

@cimport("stdio.h") // → c.stdio
@cimport("SDL2/SDL.h") as gfx // → c.gfx (rename the submodule)
unsafe c.stdio.printf("count=%d\n", n) // every C call is 'unsafe', qualified under 'c.'

Everything from C stays under c., so the dot marks “this is C” on every contact and FFI never pollutes code that does no FFI. Fundamental types come qualified too (size: c.gcc.size_t), C’s NULL becomes Optional at the boundary, and your structs going to C wear @repr(c) for a faithful layout. You can also go the other way (--emit=lib produces a .a plus a C header), and mk cc works as a drop-in C/C++ compiler. This is the “the world runs in C” bet, followed through.

The verification layer answers a common complaint: “I don’t want to fight the borrow checker, I already fight my own bugs.” In Makoto the compiler fights the bugs for you, opt-in, only where you ask.

Every guarantee is the same shape: an obligation on a predicate. There’s a discharge ladder, and the compiler discharges each obligation as high up as it can. An obligation that can’t be met at one rung falls to the next, never off the ladder:

Rung How it’s discharged Example
0 Type-level: the bad state can’t be written a dropped @must_consume; Vec[T,0].head
1 Comptime fold: the compiler computes it Index[8] with the literal 3
2 Summoned proof: an SMT solver / model checker dynamic arithmetic refinement, a liveness property
3 Runtime check: a trap on violation @requires, a refinement that reached runtime
4 Advisory: a warning, no cost the memory analyzer’s hints
5 Human: you sign for it, in writing assume "..."

The language always tells you which rung you landed on.

Ownership. Use a thing the right number of times. @must_consume = linear (exactly once), @consume_once = affine (at most once):

decl Transaction @must_consume { conn: *Connection } // must be committed or rolled back
fn commit(t: Transaction @consume) { ... }
fn rollback(t: Transaction @consume) { ... }
fn handle(t: Transaction) {
if ok { commit(t) }
// ERROR at compile time: on the else path, 't' is never consumed.
}

Forgetting a transaction becomes a build error instead of a leak you find at 2 a.m. Typestate (“a file that is Open then Closed”) is the same idea indexed by a value: calling close on a File[Closed] doesn’t typecheck.

Refinements. A type that carries a promise, so the body needs no check and no Optional:

alias Balance = i64 @where(i64 >= 0) // reference-by-type (no binder)
alias NonEmpty[T] = (xs: List[T]) @where(xs.len > 0) // named slot for the generic case
fn first[T](xs: NonEmpty[T]) -> T { return xs[0] } // provably safe → zero checks emitted
let ys: List[int] = read_input()
if ys.len > 0 { first(ys) } // the guard NARROWS ys to NonEmpty[int] in this branch
// first(ys) // ERROR: List[int] is not NonEmpty[int]

Fences. @pure (no side effects) and @total (defined on every input, always terminates). Both are inferred by default; writing the decorator demands the check. A comptime function used in a type must be @total, which is what stops a type from hanging the compiler.

Session types. Put the protocol in the channel’s type, so getting the order wrong is a compile error instead of a hung process. No !?&+ line noise; just match, loop, and continue:

@protocol decl KVServer {
loop {
@receives match { // the client picks the op each round
Get => { Key @receives; Value @sends; continue }
Put => { Key @receives; Value @receives; Ack @sends; continue }
Quit => _ // ends the session
}
}
}
let c: Channel[KVServer]
spawn client(ch) // ch: Channel[dual KVServer] — the compiler derives the mirror and checks it

dual mirrors the whole protocol for the other end, for free. A plain Channel[T] stays a plain pipe; you summon the discipline only where a conversation is worth pinning down.

Dependent types and proofs (opt-in, @proof). Types can mention values (Vec[T, n]), and a proof is a @total @pure recursive function (Curry-Howard, like Agda/Idris). This is decades-old, stable theory, so it lives in the core rather than as a bolt-on. Model checking (@spec plus spec.always/spec.eventually) proves things about a design, as an extension.

And the honest bottom rung: when you truly know better than the checker, you sign for it, in writing:

return unsafe raw_read(buf.ptr + i) assume "valid ptr: Buffer invariant + the @requires bound"

No hidden UB license and no silent skip, just a human assertion with your name on it (誠).

That’s the tour: values without null, functions with no implicit return, one decl for three contract shapes, errors as values, colorless collections, comptime metaprogramming, isolated processes over typed channels, supervision that is the code, memory from GC default down to arenas and C, first-class C FFI, and a verification ladder that runs from “the bad state can’t be written” to “I signed for it.”

None of it is a mode you switch on; each is invisible until you reach for it. Consistent, not simple: you only pay for what you summon.

(Makoto is in design and closed-beta; the syntax here follows the current design docs. Come poke holes.)