A guided tour
Build a small concurrent metrics pipeline and meet every construct doing a real job.
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”.
0 · The pitch (why it exists)
Section titled “0 · The pitch (why it exists)”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:
spawnmakes cheap processes; they talk over typed channels. - Rust’s types, without the lifetimes: sum types, generics, exhaustive matching, no borrow checker, and no
'ainfecting 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 isOptional[T], explicit in the type. - No
void/unit. A function that returns nothing just has no-> T. - No
asynccoloring. There is noasync/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' = mutableconst MAX_BATCH := 4096 // compile-time constant
port: u16 = 8080 // explicit type, written 'name: T = v'ratio: f64 = 0.75Two rules that kill whole classes of bugs:
- Immutable is the default. You write
varonly when you actually mutate.:=(or: T =) declares; a bare=only assigns to avar. - There is no
null. Absence is a type you can see:
last_error: Optional[string] = none // 'none' = absentfirst := some("cpu") // 'some(x)' = presentThere’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.
2 · Functions and control flow
Section titled “2 · Functions and control flow”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 { } // infiniteloop running { } // whileloop 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 pathdecl 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 builtinenum { 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.
5 · Collections, slices, and strings
Section titled “5 · Collections, slices, and strings”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 "{{ }}".
6 · Generics and comptime
Section titled “6 · Generics and comptime”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 spawnspawn 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 necessaryrb := -> cbWait 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 | ProcessDown8 · Resilience (supervision is the code)
Section titled “8 · Resilience (supervision is the code)”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 afterwardOOM 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.
10 · First-class C FFI
Section titled “10 · First-class C FFI”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.
11 · The verification spectrum
Section titled “11 · The verification spectrum”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 itdual 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 (誠).
The whole thing
Section titled “The whole thing”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.)