Skip to content

Kiroha language guide

This guide teaches Kiroha 0.6.0 / Web1 by example. See Types and simple generics for explicit annotations, local inference and the distinction between unknown JSON and an explicit any. The normative definition is spec.md; the built-in reference lists every function. Examples show the result after //.

1. A first program

A Kiroha program is a sequence of items separated by ;. The value of the last expression is the result.

greeting = "hello";
names = ["ada", "lin"];
names.map{n => greeting + ", " + n}
// ["hello, ada","hello, lin"]

Run it with kiroha run hello.kiroha --json. Every program is fully checked (lexed, parsed and name-resolved) before anything runs, so a typo in a branch that never executes is still an error.

2. Lexical basics

  • Source is strict UTF-8 without a byte-order mark.
  • Whitespace is space, tab, CR and LF; newlines are not statement terminators. Use ; between sequence items.
  • Comments: // to end of line. There are no block comments.
  • Identifiers: [A-Za-z_][A-Za-z0-9_]*, case-sensitive.
  • Reserved words: null true false if then else do try catch fun end.
  • Numbers use JSON syntax without a sign: 0, 12, 1.5, 2e-3. 01, .5, 1., 1abc are lexical errors. -1 is unary minus applied to 1.
  • Strings use double quotes and JSON escapes (\n, \", \u00e9, surrogate pairs). There is no interpolation.
  • q immediately followed by a backtick starts a SQL query literal (see §14).

3. Values

Kind Examples Notes
null null
bool true, false no truthiness
number 1, 0.1, 1e100 exact decimals
string "héllo" Unicode scalar values
array [1, "a", [true]] heterogeneous, immutable
record {a:1, "b c":2} ordered, immutable
function {x => x+1}, text
bytes encode("x") octets
schema int, maybe(str)
query q`select 1` opaque SQL
database, transaction, reply from db, tx, reply opaque handles

Numbers are exact: 0.1 + 0.2 is exactly 0.3, and 9007199254740993 + 1 is 9007199254740994. Only division rounds, to 34 significant digits: 1/3 is 0.3333333333333333333333333333333333.

Records keep insertion order. Keys that are identifiers need no quotes, and {x} is shorthand for {x:x}:

x = 9; {x, if:1, "0":2}     // {"x":9,"if":1,"0":2}

Duplicate keys (including escaped equivalents such as {a:1,"a":2}) are a parse error. See values and numbers.

4. Sequences, bindings and functions

Sequences appear at the top level, in do { ... }, and in named-function, lambda and catch-handler bodies.

total = 1 + 2;              // binding
fun double(n:num)         // function definition
  n * 2
end fun double
double(total)               // 6

Every named definition starts with fun name(parameters) and ends with end fun name. Repeat the exact, case-sensitive opening name at the end; omitting it or closing a different function is a parse error. The body is a sequence: write local bindings and nested definitions directly. Bindings and expressions need semicolons between items; definitions delimit themselves. No then follows the function header. Conditional if ... then keeps its then. fun and end are reserved, but are still legal record keys. The old double(n)=n*2 spelling is invalid in 0.6.0. See the 0.4 → 0.5 migration guide.

Rules worth remembering:

  • No hoisting. A name is visible only after its binding. fun f() g() end fun f fun g() 1 end fun g is a check error.
  • Named recursion only. A definition can call itself: fun fac(n:int):int if n<=1 then 1 else n*fac(n-1) end fun fac.
  • No rebinding in one scope. x=1; x=2 is a duplicate_binding error, but inner scopes may shadow: x=1; do { x=2; x } is 2, and the outer x is still 1.
  • Closures capture lexically. x=1; fun f() x end fun f do { x=2; f() } is 1.
  • Sequence value. The final expression is the value; an empty sequence or one ending in a binding/definition is null. A trailing ; changes nothing: do { 1; } is 1, do { x=1; } is null.
  • Proper tail calls. Tail-recursive loops run in constant space:
fun count(n:int, acc:int):int
  next = acc + 1;
  if n == 0 then acc else count(n - 1, next)
end fun count
count(100000, 0)            // 100000

5. Lambdas and the callback slot

Lambdas are written in braces with an arrow:

inc = {x:num => x + 1};
add = {(a:num, b:num) => a + b};
seven = {() => 7};
[inc(1), add(2, 3), seven()]    // [2,5,7]

{x => ...}, {() => ...} and {(a, b, ...) => ...} are the only heads; {(x) => x} is invalid. Braces without a lambda head are records.

The trailing callback

Every call has two channels: ordinary arguments in parentheses and at most one trailing lambda. Builtins such as map take their callback this way:

[1, 2, 3].map{x => x * 10}     // [10,20,30]
[1, 2, 3].fold(0){(acc, x) => acc + x}   // 6

A user function declares the callback slot after its parameters:

fun apply<T,U>(x:T){f:(T)=>U}:U f(x) end fun apply
apply(10){v => v + 1}           // 11

A full {IDENT} immediately after the parameter list declares the slot. For a first-body shorthand record, use {x:x} or do{{x}}.

The slot is a separate, required channel. These are all errors:

apply(10)                  check arity error: missing callback
apply(10, {v => v + 1})    parse error: a lambda literal is never an ordinary argument
g = {v:num => v + 1}; apply(10, g)   check arity error: g fills an ordinary position
apply(10){g}               parse error: the trailing callback must be a lambda literal

To pass an existing function, wrap it: apply(10){v => g(v)}. Functions can still be ordinary data — stored in records, arrays, or passed to parameters that expect them: fun invoke<T,U>(f:(T)=>U, x:T):U f(x) end fun invoke invoke(g, 2).

Ordinary calls require parentheses: now(), f(){x=>x}. For a direct member call with no ordinary arguments, the callback may follow the name: xs.map{x=>x+1}. Use .sum() without a callback and .size for length.

6. Pipes

lhs | f(a, b) evaluates lhs once and passes it as the first ordinary argument: it means f(lhs, a, b). The callback stays separate:

[1, 2, 3].as[[int]] | dump()     // "[1,2,3]"

A pipe stage is always a name or dotted path followed by (...): [1] | dump and [1] | (dump)() are parse errors. Dotted paths use ordinary member selection, including record fields: ns={show:dump}; [1] | ns.show(). For collection work, use method chains: [3,1,2].sort().map{x=>x*2}.sum().

To pipe the result of if or try, parenthesize it: (try risky() catch {e => null}) | f().

7. Operators and control flow

Precedence, highest first:

Level Operators Associativity
postfix call f(), .k, ?.k, [i], ?[i] left
unary !, -
multiplicative *, /, % left
additive +, - left
comparison <, <=, >, >=, ==, != at most one per expression
and && left
or || left
coalesce ?? right
pipe | left
  • + adds two numbers, concatenates two strings, or concatenates two arrays. Anything else is a type error — there is no coercion (1 + "x" fails).
  • % is the remainder of the exact quotient truncated toward zero; the sign follows the left operand: -5 % 2 is -1.
  • Comparisons do not chain: a < b < c and a < b == true are parse errors; write (a < b) == true.
  • ==/!= compare JSON values structurally ({a:1,b:2} == {b:2,a:1.0} is true); comparing functions, bytes or handles is a type error.
  • &&, || and if require booleans. There is no truthiness: if 0 then ... is a type error.
  • ?? replaces only null: 0 ?? 7 is 0, null ?? 7 is 7.
fun grade(n:num)   if n >= 90 then "A" else if n >= 80 then "B" else "C" end fun grade
grade(85)                       // "B"

if and try must start an expression; use parentheses inside arithmetic: 1 + (if c then 2 else 3). do { ... } is the block expression.

8. Access: strict and optional

Strict access fails loudly:

{x:1}.y      // runtime missing error, data {key:"y"}
[1][2]       // runtime bounds error, data {index:2,length:1}

Optional access returns null only for absence:

[{}?.missing, [1]?[4], null?.anything, {x:null}?.x]   // [null,null,null,null]

Wrong types are still errors ([1]?[-1] is an argument error; "a"[0] is a type error). There is no string indexing.

9. Errors

Ordinary runtime failures are records {code, message, data} and can be caught:

fun safe_div(a:num, b:num)   try a / b catch {e => e.code} end fun safe_div
[safe_div(1, 4), safe_div(1, 0)]       // [0.25,"number"]

Raise your own with fail:

fun check_age(n:num)   if n < 0 then fail({code:"bad_age", message:"negative", data:{n}}) else n end fun check_age
try check_age(-1) catch {e => e.data}  // {"n":-1}
  • Branch on e.code, never on e.message (messages are diagnostic text).
  • A handler does not catch its own failures.
  • Aborts — cancellation, deadlines, resource exhaustion — are not errors and bypass every try. See errors and aborts.

10. Schemas

Schemas validate data without coercion. The atoms are str num int bool json; [S] is an array of S; {k: S} is a closed record.

type User = {id:int, name:str, email:maybe(str), nick:optional(str)};
({id:1, name:"ada", email:null}).as[User]
// {"id":1,"name":"ada","email":null}
  • maybe(S) allows null.
  • optional(S) allows the field to be absent (but not null); combine as optional(maybe(str)).
  • open(R) allows and preserves extra JSON fields.
  • .as[...] returns its input unchanged or raises validation with {path, expected, actual} for the first failure:
try ({users:[{id:1},{id:"x"}]}).as({users:[{id:int}]}) catch {e => e.data}
// {"path":["users",1,"id"],"expected":"int","actual":"string"}

A malformed schema ([], [str,num], true, optional(str) at the root) raises schema before any data is examined.

11. Working with data

Collections are immutable; functions return new values.

orders = [{c:"a", n:2}, {c:"b", n:5}, {c:"a", n:1}];
[
  orders.filter{o => o.n > 1}.size,
  orders.sort{o => o.n}.map{o => o.n},
  orders.group{o => o.c}.map{g => {c:g.key, total: g.items.map{o => o.n}.sum()}},
  orders.unique{o => o.c}.size
]
// [2,[1,2,5],[{"c":"a","total":3},{"c":"b","total":5}],2]

Text and JSON:

["a,b,,c".split(","), ["x","y"].join("-"), "aaaa".replace("aa", "b"),
 text(1.50), number("1.25e2"), parse("{\"a\":[1]}"), dump({a:[1, 2.0]})]
// [["a","b","","c"],"x-y","bb","1.5",125,{"a":[1]},"{\"a\":[1,2]}"]

Records: keys, has, pick, merge (shallow, right wins, positions kept), one (exactly one element). Bytes: encode/decode (strict UTF-8).

12. Concurrency, retries and pagination

Everything is sequential unless you ask for concurrency with parallel:

urls.parallel(4){u => get(u)}     // at most 4 requests in flight, results in input order

The first failure cancels the remaining children and is re-raised. To collect per-item failures, catch inside the callback:

urls.parallel(4){u => try {ok:get(u)} catch {e => {error:e.code}}}

retry re-runs a zero-argument callback with exponential backoff, only when your predicate says so:

retry({attempts:3, when:{e => e.code == "timeout" || e.code == "network"}, delay:100}){() =>
  get("https://api.example/flaky")
}

pages follows cursors until next is null:

type Page=open({items:[json],next:maybe(str)});
seed:maybe(str)=null;
pages(seed){cursor =>
  p = (get("https://api.example/items", {query:{cursor}})).as[Page];
  {items:p.items, next:p.next}
}

sleep(ms) waits without blocking other tasks.

13. HTTP client

r = http("GET", "https://api.example/raw", {headers:{Accept:"text/plain"}, timeout:5000});
[r.status, r.headers["content-type"], decode(r.body)]

get("https://api.example/users", {query:{page:2}})       // parsed JSON, requires 2xx
post("https://api.example/users", {name:"ada"})            // JSON body, parsed JSON reply
url("https://api.example/v1", ["users", "a/b"], {q:"x y"})
// "https://api.example/v1/users/a%2Fb?q=x%20y"
  • http returns every final status (including 4xx/5xx) as data; get/post raise http with {status} for non-2xx.
  • No redirects, retries, cookies or ambient credentials. TLS is always verified.
  • Response gzip is decoded; max_bytes limits the decoded body.
  • The origin must be listed in the host's capabilities.http.

14. Databases

PostgreSQL, MySQL and MariaDB share this API. The examples in this section use PostgreSQL SQL syntax. See the database guide for MySQL/MariaDB host configuration, portable examples and result type differences.

Queries are written as query literals; ${...} holes become bound parameters, never text:

d = db("main");
name = "x'); drop table users; --";
sql(d, q`select id, name from users where name = ${name}`).rows

Kiroha also supports sql[User](d){select ...}, which validates each returned row as User and returns [User], and sql(d){select ...}, which returns {rows, count}. See the database guide for details.

  • sql returns {rows, count}; count is the server's affected/returned count.
  • Holes are only allowed in value positions outside quotes and comments; one statement per query; only SELECT/INSERT/UPDATE/DELETE/MERGE (optionally with WITH).
  • Results map exactly: integers and numeric become exact numbers, text types strings, json/jsonb parsed JSON, bytea bytes. Other types (dates, floats, arrays) must be converted in SQL, e.g. created_at::text, to_jsonb(tags).

Transactions:

tx(d){t =>
  row = sql(t, q`insert into users(name) values (${"ada"}) returning id`).rows.one();
  sql(t, q`insert into audit(user_id) values (${row.id})`);
  row
}

The callback's normal result commits; any failure rolls back. A failed sql(t, ...) poisons the transaction even if you catch it — the transaction will roll back and report that first SQL error. Only sql(t, ...) joins the transaction; sql(d, ...), HTTP and files inside the callback are independent.

15. HTTP server

d = db("main");
serve({
  "GET /health": {r => {ok:true}},
  "GET /users/:id": {r => sql(d, q`select id, name from users where id = ${number(r.params.id)}::int`).rows.one()},
  "POST /users": {r =>
    u = input(r, {name:str});
    reply(201, sql(d, q`insert into users(name) values (${u.name}) returning id`).rows.one())
  }
}, {port: 8080})
  • Handlers receive {method, path, params, query, headers, body}.
  • A JSON result is a 200 application/json response; use reply(status, v, headers) for other statuses or raw bytes.
  • input(r, schema) checks Content-Type: application/json, parses and validates; failures become 400/415.
  • Unknown paths are 404, wrong methods 405 with Allow; uncaught errors are a generic 500. Deadlines give 504, overload 503, large bodies 413.
  • serve runs until the process is cancelled (Ctrl-C), which the CLI reports as a cancelled abort.

16. Local effects

token = env("API_TOKEN");          // only names listed in the host file
write("out/report.json", encode(dump({ok:true})));   // atomic replacement
data = decode(read("in/data.txt"));
log({event:"done", at:now(), id:uuid()});           // one JSON line on stderr

Each needs its capability (env, write, read, clock, random, log).

17. Things Kiroha deliberately does not have

No assignment or mutation, no loops (use recursion and collection functions), no return, no imports, no classes or methods, no default parameters in user functions, no truthiness or implicit conversion, no string interpolation, no positional callbacks, no async/await syntax (effects suspend implicitly), and no hidden retries, redirects or cookies.