Language Reference
Koja is a statically typed, compiled language targeting native binaries via LLVM, with no garbage collector. It combines a Rust-inspired type system, Swift-style value semantics, an Erlang-style concurrency model, and Ruby-inspired syntax. The compiler itself is implemented as a Rust workspace.
New to Koja? Install the compiler, then start with value semantics, packages, concurrency, and tooling.
Table of Contents
- Lexical Structure: Comments, Identifiers, Keywords, Operators, Numeric Literals, Line Continuation
- Variables and Constants: Assignment, Type Annotations, Compound Assignment, Constants
- Functions: Declaration, Private Declarations,
return, Parameters - Control Flow:
if/else,unless,while,loop/break,for…in, Ternary - Types: Primitives, Numeric Widening, Arithmetic Faults, Unit, Strings, Structs, Enums, Union Types, Generics
- Pattern Matching:
match, OR Patterns,cond - Closures and Function Types: Block Closures, Short Closures, Capture Semantics, Function Types
- Value Semantics: Rules, Copy Cost, Field Access
- Protocols: Behavioral Contracts, Static Dispatch
- Packages: Transparent Files, Visibility, Aliases, Dependencies
- Concurrency: Processes,
spawn/receive,Ref,ReplyTo,Task - Standard Library: Built-in Functions, Core Types, Collections, String Methods, Binary/Bits, File I/O, Parsing, URI, Base, Path, Protocols
- C FFI:
@extern "C",CPtr<T>,CString - Annotations:
@doc,@test - Tooling: CLI Commands, LSP, Formatter
Lexical Structure
Comments
Line comments start with # and extend to the end of the line. There are no block comments.
# This is a comment
x = 42 # inline comment
Identifiers
- Values use
snake_case: variables, functions, parameters, fields. - Types use
PascalCase: structs, enums, protocols, type parameters, primitives. - Identifiers may contain
?(conventionally for boolean-returning functions likeempty?(),some?()).
Keywords
after, alias, break, cond, const, else, end, enum, extend, false,
fn, for, if, impl, in, loop, match, not, priv, protocol, receive,
return, self, spawn, struct, true, type, unless, when, while
and and or are operator-identifiers, not reserved keywords. They act as infix boolean operators in expressions (a and b, x or y) but can also be used freely as method names, function names, or field names (e.g., option.or(default)).
Operators
Precedence from lowest to highest:
| Precedence | Operators |
|---|---|
| 1 | or |
| 2 | and |
| 3 | not (prefix) |
| 4 | == != < > <= >= |
| 5 | + - <> |
| 6 | * / % |
| 7 | - (unary negation) |
| 8 | .field .fn() () |
and and or evaluate left to right and short-circuit. a and b
evaluates b only when a is true. a or b evaluates b only when
a is false. Both operands are still typechecked as Bool.
<> concatenates String, Binary, and Bits values. Both operands must be the same type, with no cross-type mixing.
Assignment operators: =, +=, -=, *=, /=.
Numeric Literals
42 # decimal integer
3.14 # floating point
0xFF # hexadecimal
0b1010 # binary
1_000_000 # underscore separators (ignored)
0xFF_FF # underscores in hex
Numeric literals coerce to any same-category type annotation. Integer literals coerce to any integer type (x: UInt8 = 4). Float literals coerce to any float type (f: Float32 = 3.14). Cross-category coercion (int to float or vice versa) is an error. Non-literal sized values widen implicitly into Int / Float. See Numeric Widening.
A literal must fit its type. An integer literal outside the target’s range is a compile-time error, and so is a float literal whose magnitude is too large for a 64-bit float (one that would round to infinity). Float values are always finite (see Arithmetic Faults).
Line Continuation
Newlines terminate statements. Line continuation is implicit after binary operators, ., and ,. A line starting with and, or, or the ternary ? also continues the previous expression, so wrapped conditions lead each continuation line with the operator.
if request.valid? and request.authorized?
and request.body.present?
handle(request)
end
Variables and Constants
Variables are declared by assignment. No let, var, or mut keywords.
x = 42
name = "koja"
Type Annotations
Optional type annotations follow the variable name with a colon:
x: Int32 = 42
z: Option<Int32> = Option.None
list: List<Int32> = List.new()
Annotations are required when no surrounding context determines the type,
such as a bare Option.None assignment.
Compound Assignment
x += 1
x -= 2
x *= 3
x /= 4
Assignment and Value Semantics
Every binding holds an independent value. Assignment copies, and the original stays usable:
p1 = Point{x: 1, y: 2}
p2 = p1
p1.x # still valid, p2 is an independent copy
Copies are observably independent for every type. Mutating one binding never affects another, and a value remains usable for as long as it is in scope.
Constants
Package-level constants are declared with const. Values can be literals (int, float, string, bool), binary literals whose segments are all literals, enum unit variants, or struct literals whose fields are all constant expressions:
const MAX = 100
const PI = 3.14
const NAME = "koja"
const DEBUG = false
const SYNC = <<0x53::8, 4::32>>
const HEADING = Direction.North
const ORIGIN = Point{x: 0, y: 0}
An optional type annotation is supported for generic inference:
const EMPTY: Option<Int> = Option.None
Constants are inlined at every usage site.
Functions
Functions are declared with fn. The last expression is the implicit return value.
fn add(a: Int32, b: Int32) -> Int32
a + b
end
Functions without a return type return (). Parameters require explicit types. Return type annotation is required if the function returns a value.
A compiled program’s entry point is a type implementing the Process protocol, named by entry in koja.toml. There is no fn main. Scripts (.kojs) execute top-level statements directly. Functions may be declared at the top level or inside struct, enum, and impl bodies. See Structs, Protocols, and Static Functions.
Private Declarations
priv restricts a declaration’s visibility based on where it appears:
- A top-level
privdeclaration (fn,struct,enum,const,type,protocol) is package-private: it’s usable from any file in the same package, but rejected from any other package. - A
priv fndeclared inside astruct,enum, orimplbody is type-private: it’s callable from any other method on the same target type (whether declared in the type’s decl block, anextend Typeblock, or animpl Protocol for Typeblock), but rejected everywhere else.
priv fn helper(x: Int32) -> Int32 # package-private
x * 2
end
priv const RETRY_LIMIT: Int32 = 3 # package-private
priv struct Bucket # package-private
count: Int32
end
struct Counter
value: Int32
fn increment(self) -> Counter
Counter { value: self.tick() } # ok: same type
end
priv fn tick(self) -> Int32 # type-private to Counter
self.value + 1
end
end
A public declaration cannot leak a private type through its signature. A public function whose parameter or return type names a private type, or a public struct field, enum variant payload, type alias, or protocol method that mentions one, is a compile error. Callers outside the package could see the type but never name it, so the compiler rejects the leak at the declaration site.
@doc on a private declaration is also a compile error. Private items never appear in generated documentation, so use regular # comments instead.
return
Explicit return is available for early exits:
fn find(items: List<Int32>, target: Int32) -> Bool
for item in items
if item == target
return true
end
end
false
end
return is a statement. It cannot appear inside another expression.
Parameters
Parameters are passed by value. The callee receives its own independent copy and the caller’s binding stays usable afterward:
fn describe(c: Config) -> String
c.name # operates on a private copy
end
There is no parameter-passing modifier. Every parameter is a value. (Earlier releases had a move keyword. It has been removed, since value semantics make it meaningless.)
Control Flow
if / else
if x > 3
"greater".print()
else
"not greater".print()
end
if/else can be used as value-producing expressions when both branches produce values.
There is no else if. For multi-way branching, use cond.
unless
unless executes its body when the condition is false. It is a
single-branch conditional and does not accept else.
unless ready?
"not ready".print()
end
while
i = 0
while i < 10
i.print()
i += 1
end
loop / break
i = 0
loop
if i >= 5
break
end
i += 1
end
break is a statement. It cannot appear inside another expression.
for … in
Iterates over any type implementing the Enumeration<T> protocol:
list: List<Int32> = List.new()
list = list.append(1)
list = list.append(2)
list = list.append(3)
for item in list
item.print()
end
The loop variable is bound directly to each element. No unwrapping needed.
Ternary
y = x > 2 ? "big" : "small"
Nested ternaries are disallowed.
Types
Primitive Types
| Type | Description |
|---|---|
Int |
64-bit signed integer (alias for Int64) |
Int8 |
8-bit signed integer |
Int16 |
16-bit signed integer |
Int32 |
32-bit signed integer |
Int64 |
64-bit signed integer (same as Int) |
UInt8 |
8-bit unsigned integer |
UInt16 |
16-bit unsigned integer |
UInt32 |
32-bit unsigned integer |
UInt64 |
64-bit unsigned integer |
Float |
64-bit IEEE 754, finite-only (alias for Float64) |
Float32 |
32-bit IEEE 754, finite-only |
Bool |
true or false |
String |
UTF-8 string |
Binary |
Arbitrary byte sequence |
Bits |
Arbitrary bit sequence |
() |
Unit type (empty value) |
Every String is valid UTF-8 and carries an authoritative byte length.
U+0000 is a valid character. Trailing NUL storage is never used to
determine a string’s contents.
Every Float and Float32 is finite. NaN and the infinities are not
representable in Koja. Every operation that would produce one traps
instead (see Arithmetic Faults), the same way
every String is valid UTF-8 by construction. Float equality is
therefore a true equivalence relation, and comparisons are total.
All types have value semantics. Assignment produces an independent copy. Numeric primitives and Bool copy bit-for-bit. String, Binary, Bits, List, Map, Set, structs, and enums copy their contents. The distinction is only one of cost, never of semantics.
Numeric Widening
Sized numeric values widen implicitly into their hub type, and only into their hub type. Int8, Int16, Int32, UInt8, UInt16, and UInt32 widen to Int (signed sources sign-extend, unsigned sources zero-extend). Float32 widens to Float. The conversion is always lossless.
fn count(n: Int) -> Int
n
end
small: Int32 = -7
count(small) # Int32 widens to Int, value stays -7
Widening applies wherever a value flows into a typed slot: call arguments, struct fields, enum payloads, return values, annotated bindings, and constant initializers. It does not apply to:
- Binary operators: operands must be the same width.
Int32 + Intis an error. Widen explicitly first. - Sideways conversions:
Int8does not widen toInt16,UInt8does not widen toUInt16. Each source type has exactly one implicit target. UInt64: it does not fit inInt. Use the checkedto_intmethod.- Generic inference:
Tbinds to the actual type.identity(small)infersT = Int32, notInt. - Narrowing or cross-category conversion:
Intnever implicitly becomesInt32, and ints never become floats.
The inverse direction is explicit and checked. Int provides to_int8, to_int16, to_int32, to_uint8, to_uint16, to_uint32, and to_uint64, each returning Result<TargetType, NumericConversionError>, with Result.Err(NumericConversionError.OutOfRange) when the value does not fit. UInt64.to_int is the checked bridge back to the hub, and Float.to_float32 rounds to the nearest representable value, with OutOfRange for magnitudes too large for a 32-bit float:
match 300.to_int8()
Result.Ok(v) -> v.print()
Result.Err(e) -> "does not fit".print() # 300 > Int8.max
end
Sized-to-sized conversions route through Int: widen up implicitly, then narrow down explicitly.
Arithmetic Faults
Arithmetic never wraps, saturates, or produces a non-finite float. An operation without a representable result panics with an ArithmeticError (Erlang’s badarith, not C’s undefined behavior). The panic is identical on both backends and in --release builds, and it follows the standard crash path. The faulting process crashes (ExitReason.Crashed), and a fault in the root process exits the program non-zero.
| Operation | Fault |
|---|---|
Int + - *, unary - |
result does not fit the operand type’s width |
Int / % |
zero divisor, or MIN / -1 |
bsl / bsr |
shift count outside 0 <= n < bit width |
Float + - * / % |
IEEE result is non-finite (NaN or infinity) |
Integer faults are checked at the operand’s declared width and signedness. UInt8 arithmetic traps past 255, not past Int.max. Comparisons never fault.
The float row is what makes the finite-only invariant airtight. 1.0 / 0.0 and 0.0 / 0.0 trap instead of minting inf / NaN. The remaining boundaries are closed to match. Float literals that would round to infinity are compile-time errors, Float.parse classifies them as OutOfRange, Float.to_float32 is checked, and a non-finite float returned by an @extern "C" call traps at the call site.
a = 9223372036854775807
a + 1 # panics: integer overflow in +
b = 0.0
1.0 / b # panics: non-finite float result in /
Unit Expression
() is the unit value. Use else -> () in cond for side-effect-only fallthrough.
Strings
Single-Line Strings
"hello world"
"tab:\there"
"quote: \"yes\""
"backslash: \\"
Escape sequences: \", \\, \n, \r, \t, \#.
String Interpolation
name = "koja"
"hello #{name}".print()
"1 + 2 = #{1 + 2}".print()
Interpolation expressions are enclosed in #{} and can contain any expression.
Multiline Strings
Triple-quoted strings with automatic dedent based on closing delimiter position:
msg = """
first line
second line
"""
Multiline strings support the same escape sequences and interpolation as single-line strings.
Structs
Declaration
struct Point
x: Int32
y: Int32
end
Construction
p = Point{x: 1, y: 2}
Short structs format inline. Long structs break across lines with trailing commas:
config = Config{
name: "production",
port: 8080,
debug: false,
}
Field Access
p.x.print()
p.y.print()
Inline Functions
Functions can be defined directly inside struct bodies alongside fields:
struct Point
x: Int32
y: Int32
fn distance_squared(self) -> Int32
self.x * self.x + self.y * self.y
end
fn origin -> Self
Point{x: 0, y: 0}
end
end
p = Point{x: 3, y: 4}
p.distance_squared().print()
Point.origin().x.print()
Methods receive self by value. A “mutating” method does not change the receiver in place. It computes a new value and returns it, and the caller rebinds:
struct Counter
value: Int
fn increment(self) -> Self
Counter{value: self.value + 1}
end
end
c = Counter{value: 0}
c = c.increment() # rebind to the returned value
Self is a shorthand for the enclosing type in return positions. Use it instead of repeating the type name.
Extend Blocks
extend blocks attach additional inherent functions to an existing type, analogous to Swift extensions. Use extend for adding functions from outside the type’s own declaration. impl is reserved for protocol conformance (impl Protocol for Type).
extend Point
fn translate(self, dx: Int32, dy: Int32) -> Self
self.x += dx
self.y += dy
self
end
end
Methods declared in an extend block have ambient visibility. They’re callable from any package that can name the target type. Collisions on the same method name across extend blocks targeting the same type are a compile error.
Static Functions
Functions without self (either inline or in extend blocks) are called on the type directly:
struct Config
port: Int
fn default -> Self
Config{port: 8080}
end
end
config = Config.default()
Concrete Extend Specialization
extend blocks can target a specific instantiation of a generic type. Methods defined in a specialized extend are only available when the type argument matches:
extend CPtr<UInt8>
fn to_cstring(self) -> CString
CString{ptr: self, len: strlen(self)}
end
end
to_cstring is only available on CPtr<UInt8>, not on CPtr<Int32> or other instantiations. Calling a specialized method on the wrong type argument produces a compile error with a hint showing which specialization provides the method.
This pointer conversion is distinct from checked
String.to_cstring(). It assumes a readable NUL-terminated C buffer
and computes CString.len with strlen.
Mixing concrete types and type parameters in the same extend block is not allowed:
# Error: mixes concrete types and type parameters
extend Map<String, V>
fn lookup(self, key: String) -> Option<V>
self.get(key)
end
end
Enums
Variants
Enums support unit, tuple, and struct variants:
enum Direction
North
South
East
West
end
enum Shape
Circle(Int32)
Rect(Int32, Int32)
end
Construction
d = Direction.North
s = Shape.Circle(5)
Within a match arm on the same enum, the type prefix can be omitted for unit variants:
fn opposite(dir: Direction) -> String
match dir
North -> "south"
South -> "north"
East -> "west"
West -> "east"
end
end
Inline Functions
Enums can also define functions directly in their body:
enum Direction
North
South
East
West
fn label(self) -> String
match self
Direction.North -> "north"
Direction.South -> "south"
Direction.East -> "east"
Direction.West -> "west"
end
end
end
Recursive Enums
Enums can reference themselves through generic containers like List<T>:
enum Expr
Num(Int)
Add(Expr, Expr)
Mul(List<Expr>)
end
Union Types
A value that can be one of several types. Use | between types:
fn display(item: Post | Comment | Ad) -> String
match item
_ -> "an item"
end
end
Use type to name a union:
type Pet = Cat | Dog | Fish
A member type widens to the union automatically:
c = Cat{name: "Whiskers"}
pet: Pet = c
Order doesn’t matter. Post | Comment and Comment | Post are the same type.
Generics
Generic Functions
fn identity<T>(x: T) -> T
x
end
identity(42).print()
identity("hello").print()
Type arguments are inferred at call sites from arguments and type annotations.
Generic Structs
struct Pair<A, B>
first: A
second: B
end
p = Pair{first: 10, second: "hello"}
Generic Enums
enum Option<T>
Some(T)
None
end
Generic enum unit variants infer from an enclosing expected type. Expected types come from annotations, function and closure returns, control-flow arms, struct fields, and generic call returns:
z: Option<Int32> = Option.None
fn empty_pair() -> Pair<Int, Option<String>>
Pair.new(1, Option.None)
end
A context-free unit variant still requires an annotation.
Annotation-Driven Inference
Type annotations on variables drive generic type inference:
list: List<Int32> = List.new() # infers T = Int32
Implementation
Generics compile via monomorphization. The compiler generates specialized native code for each concrete type instantiation. Unused instantiations produce no binary output.
Pattern Matching
match
Pattern matching with exhaustiveness checking:
result = match x
1 -> "one"
2 -> "two"
_ -> "other"
end
Patterns: literals (integers, floats, booleans, strings), wildcards (_), variable bindings, nested patterns, enum and struct destructuring. Guards use when:
match x
Option.Some(v) when v > 5 -> "big"
Option.Some(_) -> "small"
Option.None -> "none"
end
An enum variant counts as exhaustively covered only when its payload
patterns match every payload value. A literal or nested pattern such as
Option.Some(Color.Red) does not cover every Some. Multiple partial
payload arms are not combined, so bind the payload and use an inner
match, or add a full payload arm such as Option.Some(_).
Struct destructuring works for both plain structs and enum-struct variants. Field syntax is always name: pattern. There is no shorthand form. To bind a field under its own name, write x: x. Unlisted fields are implicit wildcards, and an empty {} matches any value of that type:
struct Point
x: Int
y: Int
end
match p
Point{x: 0, y: 0} -> "origin"
Point{x: 5} -> "x is five" # y is unconstrained
Point{x: x, y: y} -> "(#{x}, #{y})"
end
# Enum-struct variants follow the same rules.
match shape
Shape.Rect{width: w, height: h} -> w * h
Shape.Circle{radius: r} -> r * r * 314 / 100
end
String literals can be used as patterns:
fn classify(c: String) -> String
match c
"0" -> "zero"
"1" -> "one"
_ -> "other"
end
end
OR patterns combine multiple patterns in a single arm with |:
match n
1 | 2 | 3 -> "small"
4 | 5 | 6 -> "medium"
_ -> "large"
end
Variable bindings inside OR patterns are disallowed.
match is value-producing when all arms produce values.
match reads the matched value without consuming it. The original variable remains live inside all arms and after the match expression.
cond
Multi-branch conditional. Koja has no else if, so cond is the idiomatic way to chain conditions. Requires an else arm:
fn classify(n: Int32) -> String
cond
n > 100 -> "big"
n > 10 -> "medium"
else -> "small"
end
end
cond is value-producing when all arms (including else) produce values.
Arms can use any boolean expression, including method calls:
cond
c.digit?() -> handle_digit(c)
c.whitespace?() -> skip_whitespace()
c == "+" -> handle_plus()
else -> handle_unknown(c)
end
Closures and Function Types
Block Closures
Closures use fn (...) -> T ... end syntax, mirroring function signatures:
double = fn (x: Int32) -> Int32 x * 2 end
add = fn (a: Int32, b: Int32) -> Int32
a + b
end
Closure parameters are passed by value, like function parameters:
measure = fn (data: String) -> Int data.length() end
Short Closures
Short closures use param -> expr syntax as direct call arguments, with parameter types inferred from the call:
option.map(x -> x + 1)
list.filter(n -> n > 3)
names.map(name -> name.upcase())
Both positional and named arguments accept the short form, including arguments to generic functions. Use the block form outside a call argument or when the closure needs multiple parameters or statements.
Capture Semantics
Closures capture variables from their enclosing scope by value. Each captured variable is copied into the closure’s environment, and the original stays usable:
multiplier = 3
triple = fn (x: Int32) -> Int32
x * multiplier # multiplier is copied into the closure
end
multiplier.print() # still live
Captured closures use heap-allocated environment structs.
Function Types
Function types are written as fn (ParamTypes) -> ReturnType:
fn apply(x: Int32, f: fn (Int32) -> Int32) -> Int32
f(x)
end
apply(5, fn (n: Int32) -> Int32 n * 2 end).print()
Value Semantics
Koja uses value semantics. Every binding, parameter, return, and field is an independent value, with memory managed automatically by the runtime.
Rules
- Assignment copies. The source remains usable.
- Function and closure parameters are passed by value. The caller’s binding survives the call.
- There is no aliasing. Mutating one binding never affects another.
- A value is usable for as long as it is in scope.
Memory note: heap-backed values (strings, collections, composites) are reclaimed by reference counting. Blocks are shared while live and freed deterministically at scope exit when the last owner drops. This is scope-bound, not a garbage collector. There are no pauses and no background collector. See the README for production-readiness status.
Copy Cost
All types copy on assignment. Numeric primitives, Bool, (), and function pointers copy bit-for-bit. String, Binary, Bits, List, Map, Set, structs, and enums are heap-backed, but the copy is cheap: the underlying memory is shared, so assigning or passing a value copies nothing. Mutation works on a fresh copy, so no binding ever observes another’s changes. (Today mutation always makes that copy. A future compiler may skip it when nothing else shares the value, with no change in behavior.) The result is always an independent value:
a = 42
b = a # a is still live
Field Access
Field access reads a value out of the struct without disturbing the owner. You can read fields freely:
struct Wrapper
name: String
count: Int
end
w = Wrapper{name: "hello", count: 1}
w.name.print() # w is still live
w.count.print()
w.name.print()
This extends to chained access and method calls:
w.name.length() # reads name, calls length. w is still live
To mutate a field, use reassignment. The right-hand side transforms the current field value and the result is written back:
w.name = w.name.upcase()
w.name.print() # "HELLO"
Protocols
Protocols define behavioral contracts. Types implement protocols via impl Protocol for Type.
protocol Greeter
fn greet(self) -> String
end
struct Cat
name: String
end
impl Greeter for Cat
fn greet(self) -> String
"meow, I'm #{self.name}"
end
end
The compiler validates completeness (all protocol functions must be implemented) and signature compatibility. priv fn helpers are allowed in impl blocks. @doc annotations are supported on protocol declarations.
Self inside a protocol declaration is syntactic sugar for an implicit first type parameter on the protocol. It is the slot every conforming type fills in via impl Protocol for ConcreteType. Methods that mention Self in their signature (return type, non-receiver param) treat it as that synthetic param. In an impl Protocol for ConcreteType block, the synthetic param resolves to ConcreteType and the method’s Self ends up typed as the concrete implementer. User-declared protocol type parameters (e.g. protocol Eq<T>) are appended after the synthetic Self slot. The name Self is reserved on protocols and cannot also be declared explicitly.
Trait Bounds
Generic type parameters can be constrained to types implementing specific protocols using : syntax:
fn say_hello<T: Greeter>(animal: T) -> String
animal.greet()
end
Multiple bounds use & (the protocol composition operator, complementing | for union types):
fn describe_and_greet<T: Greeter & Description>(animal: T) -> String
animal.describe() <> " says " <> animal.greet()
end
Bounds are verified at call sites. If a concrete type doesn’t implement a required protocol, the compiler emits an error:
type `Cat` does not implement protocol `Description` (required by type parameter `T` in `describe_and_greet`)
Inside the function body, protocol methods can be called directly on bounded type parameters. The compiler resolves the method through the protocol’s signature.
Unbounded type parameters (<T>) remain valid and backwards compatible.
Dispatch
Protocol dispatch is static via monomorphization. No vtables, no dynamic dispatch.
Packages
A package is the unit of code organization, defined by a koja.toml manifest. Files within a package are transparent: they share one namespace, and every top-level declaration (type, function, constant) is visible from every other file in the package. Files carry no namespace of their own, and there are no imports:
# src/helper.koja
fn add(a: Int, b: Int) -> Int
a + b
end
# src/app.koja
struct App
end
impl Process<(), (), ()> for App
fn start(config: ()) -> Result<Self, StopReason>
Result.Ok(App{})
end
fn handle(self, msg: (), from: Option<ReplyTo<()>>) -> Step<Self>
Step.Continue(self)
end
fn run(self) -> StopReason
add(3, 4).print()
StopReason.Normal
end
end
Other packages (the qualified standard library and dependencies) are reached through their package name: JSON.Decoder, Net.TCPSocket, HTTP.get(...).
Visibility
Access control is at the declaration level (priv), not the file level:
- A top-level
privdeclaration (fn,struct,enum,const,type,protocol) is package-private: usable from any file in the same package, rejected from other packages. priv fndeclared inside astruct,enum,extend, orimplbody is type-private: callable from any other method on the same target type (across the decl block and anyextendorimpl Protocol for Typeblock on that type), rejected everywhere else.
See Private Declarations for examples.
Aliases
When using types from qualified standard library packages or dependency packages, alias creates a file-private shorthand:
alias Net.TCPSocket
alias JSON.Decoder
alias JSON.Encoder as JSONEncoder
conn = TCPSocket.connect("example.com", 80)
alias Net.TCPSocket makes TCPSocket available as a local name. alias JSON.Encoder as JSONEncoder binds a custom local name. Aliases are scoped to the declaring file and don’t affect other files.
Aliases name types only. Package-level functions are called with qualified syntax directly, no alias needed:
response = HTTP.get("https://example.com")
Standard library visibility
The auto-imported Global package provides core types (Option, Result, List, Map, Set, Process, IO, File, URI, Base, Path, etc.) with no alias needed. Domain-specific packages require qualified access:
Crypto:SHA1,SHA256,SHA384,SHA512,HMAC,Certificate,PrivateKey,PEMErrorNet:TCPSocket,TCPListener,UDPSocket,Socket,IPAddress,SocketAddress,SocketKind,SocketError,TLSSession,TLSConfig,TLSIdentity,TrustStore,TLSError,VerificationError
Use alias Crypto.SHA256 or alias Net.TCPSocket to access them.
Dependencies
Packages declare dependencies in koja.toml, by local path or by git repository pinned to an exact ref:
[dependencies]
Postgres = { github = "koja-lang/postgres", tag = "v0.1.0" }
Vendored = { git = "https://git.example.com/vendored.git", branch = "main" }
Greeter = { path = "libs/greeter" }
Each dependency declares exactly one of path, git, or github (an owner/repo shorthand for https://github.com/owner/repo). Git dependencies accept at most one of tag, branch, or rev. With none, the remote’s default branch is used. There is no version solver: a ref resolves to a commit, and one version of a package name exists per build.
koja deps get is the only command that touches the network. It resolves each ref to a commit SHA, records the pin in koja.lock (committed, so builds are reproducible), caches a mirror clone under ~/.koja/cache, and copies the pinned tree into the project’s deps/ directory (gitignored and read-only, always regenerable). Dependencies of dependencies resolve transitively, and the root project’s lockfile is the only one consulted.
Every other command is offline. build, check, run, test, and doc verify koja.lock against the manifest and re-materialize deps/ from the local cache when needed. A manifest edit that outdates the lock, or a pin missing from the cache, is a hard error naming the fix rather than a silent fetch:
error: dependency `postgres` is not pinned in koja.lock (koja.toml changed?), run `koja deps get`
koja deps prints each dependency with its pin and local state. koja deps update [name] re-resolves refs against their remotes (moving a branch pin forward). koja deps clean removes deps/. With --cache it also purges the global mirror cache.
Private repositories work through the ambient git configuration: SSH agents for git@ URLs, credential helpers for https, and insteadOf rewrites in CI. Credentials never appear in koja.toml or koja.lock.
Concurrency
Koja uses a message-passing actor model inspired by Erlang/Elixir. Processes have isolated memory and communicate exclusively through typed messages. Messages are passed by value (each process receives its own copy), so there is no shared mutable state.
Process timeout and delay values are measured in milliseconds. Negative values behave as zero.
Task<R>
The simplest way to run concurrent work. Wraps a closure, runs it in a spawned process, and returns the result:
ref = Task.async(fn -> expensive_computation() end)
result = Task.await(ref) # Result<R, CallError>, times out after 5000ms
Task.async(fn) spawns the closure and returns a Ref<(), R>. Task.await(ref) sends a unit message and waits for the reply.
Process<C, M, R> Protocol
For stateful, long-lived processes, implement the Process protocol. C is the config type, M is the message type, R is the reply type.
protocol Process<C, M, R>
fn start(config: C) -> Result<Self, StopReason>
fn handle(self, msg: M, from: Option<ReplyTo<R>>) -> Step<Self>
fn handle_signal(self, event: Lifecycle) -> Step<Self>
fn run(self) -> StopReason
end
start builds the initial state from config in the child process context, before the receive loop begins. Return Result.Ok(self) to begin running, or Result.Err(reason) to abort startup.
handle returns Step<Self>. Return Step.Continue(self) to keep running with updated state, or Step.Done(reason) (with a StopReason of Normal or Shutdown) to stop.
handle_signal has a default implementation that stops on Shutdown/Interrupt and continues on Reload. Override it for graceful drain or hot config reload.
run has a default implementation that enters a receive loop, dispatching business messages to handle and lifecycle events to handle_signal, and stopping when either returns Step.Done:
fn run(self) -> StopReason
receive
pair: Pair<M, Option<ReplyTo<R>>> ->
match self.handle(pair.first, pair.second)
Step.Continue(next) -> next.run()
Step.Done(reason) -> reason
end
event: Lifecycle ->
match self.handle_signal(event)
Step.Continue(next) -> next.run()
Step.Done(reason) -> reason
end
end
end
A complete process example:
enum CounterMsg
Increment
Decrement
end
struct Counter
count: Int
end
impl Process<Counter, CounterMsg, Int> for Counter
fn start(config: Counter) -> Result<Self, StopReason>
Result.Ok(config)
end
fn handle(self, msg: CounterMsg, from: Option<ReplyTo<Int>>) -> Step<Self>
next_count =
match msg
CounterMsg.Increment -> self.count + 1
CounterMsg.Decrement -> self.count - 1
end
ReplyTo.reply(from, next_count)
Step.Continue(Counter{count: next_count})
end
end
ref = spawn Counter.start(Counter{count: 0})
ref.cast(CounterMsg.Increment)
count = ref.call(CounterMsg.Increment, 5000)
Lifecycle and StopReason
Lifecycle abstracts OS signals into a platform-agnostic enum:
enum Lifecycle
Shutdown # SIGTERM
Interrupt # SIGINT
Reload # SIGHUP
end
StopReason represents intentional process termination:
enum StopReason
Normal # process finished its work
Shutdown # process was told to stop
end
The ExitStatus protocol maps a StopReason to an OS exit code (only relevant for the entry process):
protocol ExitStatus
fn code(self) -> Int
end
ExitReason is what a supervisor sees when a child stops:
enum ExitReason
Normal
Shutdown
Crashed(String)
end
Ref<M, R>
spawn returns a typed handle to the running process. M is the message type the process accepts, and R is the reply type.
struct Ref<M, R>
id: Int
end
Operations on a process handle:
cast(msg: M): fire-and-forget. The handler receivesfrom = Option.None.call(msg: M, timeout: Int) -> Result<R, CallError>: sends a message and blocks up totimeoutmilliseconds for a reply. ReturnsResult.Ok(reply)on success,Result.Err(CallError.Timeout)if the process didn’t reply in time, orResult.Err(CallError.ProcessDown)if the process is dead.signal(event: Lifecycle): sends a lifecycle signal to the process (e.g.Lifecycle.Shutdown). Delivered tohandle_signal.kill(): immediately terminates the process. No signal is sent.alive?() -> Bool: returnstrueif the process is still running.send_after(msg: M, delay_ms: Int): schedulesmsgfor delivery afterdelay_msmilliseconds. The message is copied immediately. Delivery happens asynchronously when the timer fires. Useful for periodic ticks and timeouts inside a process loop.
Ref.self_ref() returns a typed handle to the current process. It must be called from within a running process (inside start, handle, or handle_signal). The type parameters are inferred from the binding’s annotation:
me: Ref<TickMsg, String> = Ref.self_ref()
me.send_after(TickMsg.Tick, 1000)
ref.cast(CounterMsg.Increment)
result = ref.call(CounterMsg.Increment, 5000)
ref.signal(Lifecycle.Shutdown)
ReplyTo<R> and reply
When a process receives a call, the handler gets a ReplyTo<R> channel to send the response back. The type R is enforced at compile time. The channel carries the caller’s process id plus a correlation token minted per call, so stale replies from earlier timed-out calls are discarded instead of delivered to the next call.
struct ReplyTo<R>
id: Int
token: Int
end
send(reply: R): sends the reply back to the caller.
ReplyTo.reply(from, value) is a convenience on ReplyTo<R> that handles the common pattern of replying only when a caller is present (skips silently for cast messages):
extend ReplyTo<R>
fn reply(from: Option<ReplyTo<R>>, value: R)
end
Call it with the handler’s from parameter directly:
ReplyTo.reply(from, self.count)
spawn and receive
The underlying keywords that power the process model. spawn creates a new lightweight process and returns a Ref. receive blocks the current process until a message arrives:
receive
pair: Pair<M, Option<ReplyTo<R>>> ->
# handle the message
end
An optional after clause bounds the wait: if no message arrives within the timeout (in milliseconds), the after body runs instead. The timeout is any Int expression:
receive
pair: Pair<M, Option<ReplyTo<R>>> ->
# handle the message
after 5000
# no message within 5 seconds
end
In most cases you won’t use receive directly. The Process protocol’s default run implementation handles it for you.
Standard Library
The following types and functions are available in every file with no alias needed.
Built-in Functions
Note: Koja uses value semantics. Every binding, parameter, return, and field is an independent value. Assigning or passing a value already yields an independent copy (cheaply, via reference- counted sharing under the hood), so there is no
clone(): just assign or pass the value.
Kernel
Core runtime operations.
Kernel.exit(code: Int)
Terminates the process immediately with the given exit code. 0 indicates success, and any non-zero value indicates failure.
Kernel.exit(0)
Kernel.panic(message: String)
Aborts the process with the given message and a symbolicated stack trace. Never returns. Used internally by unwrap() on Option.None and Result.Err.
Kernel.panic("something went wrong")
Option<T>
enum Option<T>
Some(T)
None
end
Functions: unwrap(), or(default), some?(), none?(), map(fn (T) -> U), then(fn (T) -> Option<U>).
x = Option.Some(42)
x.unwrap().print() # 42
x.or(0).print() # 42
x.some?().print() # true
y: Option<Int> = Option.None
y.or(99).print() # 99
mapped = x.map(fn (v: Int) -> Int v * 10 end)
mapped.unwrap().print() # 420
Result<T, E>
enum Result<T, E>
Ok(T)
Err(E)
end
Functions: unwrap(), or(default), ok?(), err?(), map(fn (T) -> U), then(fn (T) -> Result<U, E>).
ok: Result<Int32, Int32> = Result.Ok(42)
ok.unwrap().print() # 42
err: Result<Int32, Int32> = Result.Err(1)
err.or(99).print() # 99
Pair<A, B>
struct Pair<A, B>
first: A
second: B
end
Fields: first, second.
p: Pair<Int, String> = Pair{first: 10, second: "hello"}
p.first.print() # 10
p.second.print() # hello
Generic struct literals like Pair{first: x, second: y} infer their type parameters from the field values when each field’s expected type-param appears in at least one field. A type annotation on the binding (p: Pair<Int, String> = ...) is only required when no positional field uniquely binds a parameter, for example a struct that only mentions some of its parameters in its fields’ types.
Range
An inclusive range with start and stop endpoints.
struct Range
start: Int
stop: Int
end
Used by String.slice for substring extraction:
greeting = "hello world"
hello = greeting.slice(Range{start: 0, stop: 4})
hello.print() # "hello"
List<T>
Dynamically-sized, heap-backed collection. Compiler intrinsic backed by C’s malloc/realloc/free.
list: List<Int32> = List.new()
list = list.append(10)
list = list.append(20)
list.length().print() # 2
list.get(0).unwrap().print() # 10
list.empty?().print() # false
append returns a new list with the element added (rebind with list = list.append(x)). The original is unchanged. get returns Option<T> (None for out-of-bounds).
Functions:
new() -> List<T>: creates an empty list.append(self, item: T) -> List<T>: appends an element.last(self) -> Option<T>: returns the last element, orNoneif empty.length(self) -> Int: returns the number of elements.get(self, index: Int) -> Option<T>: returns the element atindex, orNoneif out of bounds.empty?(self) -> Bool: returnstrueif the list has no elements.map(self, f: fn (T) -> U) -> List<U>: returns a new list withfapplied to each element.filter(self, f: fn (T) -> Bool) -> List<T>: returns elements for whichfreturnstrue.any?(self, f: fn (T) -> Bool) -> Bool: returnstrueiffreturnstruefor at least one element.all?(self, f: fn (T) -> Bool) -> Bool: returnstrueiffreturnstruefor every element. Returnstruefor an empty list.
nums = [1, 2, 3, 4, 5]
doubled = nums.map(fn (n: Int) -> Int n * 2 end)
evens = nums.filter(fn (n: Int) -> Bool n % 2 == 0 end)
has_big = nums.any?(fn (n: Int) -> Bool n > 3 end)
all_pos = nums.all?(fn (n: Int) -> Bool n > 0 end)
List literals ([a, b, c]) are backed by the ListLiteral<T> protocol. See Literal Protocols.
Map<K, V>
A generic hash map. Keys must implement Hash and Equality. Uses open addressing with linear probing.
m: Map<String, Int> = Map.new()
m = m.put("a", 1)
m = m.put("b", 2)
m.get("a").unwrap().print() # 1
m.has?("b").print() # true
m.length().print() # 2
Functions:
new() -> Map<K, V>: creates an empty map.put(self, key: K, value: V) -> Map<K, V>: inserts or updates a key-value pair.get(self, key: K) -> Option<V>: returnsOption.Some(value)if the key exists,Option.Noneotherwise.has?(self, key: K) -> Bool: returnstrueif the key exists.remove(self, key: K) -> Map<K, V>: removes the entry for the key. Returns the map unchanged if the key is absent.length(self) -> Int: returns the number of entries.empty?(self) -> Bool: returnstrueif the map has no entries.
Map does not currently support iteration. To iterate over entries, use List<Pair<K, V>> as an ordered key-value collection instead.
Map literals ([key: value, ...]) are backed by the MapLiteral<K, V> protocol. See Literal Protocols.
Set<T>
A generic hash set of unique elements. Elements must implement Hash and Equality. Uses open addressing with linear probing.
s: Set<Int> = Set.new()
s = s.insert(1)
s = s.insert(2)
s = s.insert(1)
s.length().print() # 2
s.has?(1).print() # true
Functions:
new() -> Set<T>: creates an empty set.insert(self, item: T) -> Set<T>: adds an element. Returns unchanged if already present.has?(self, item: T) -> Bool: returnstrueif the element exists.remove(self, item: T) -> Set<T>: removes the element. Returns unchanged if absent.length(self) -> Int: returns the number of elements.empty?(self) -> Bool: returnstrueif the set has no elements.
Set<T> implements ListLiteral<T>, so list literal syntax constructs a set when the target type is Set<T>:
names: Set<String> = ["alice", "bob", "alice"] # Set with 2 elements
String Methods
String implements Enumeration<String>, so strings can be iterated character-by-character with for:
for c in "hello"
c.print()
end
Functions:
length(self) -> Int: returns the number of Unicode codepoints.get(self, index: Int) -> Option<String>: returns the single-character string at the given index, orNoneif out of bounds.alpha?(self) -> Bool: returnstrueif the string contains only ASCII alphabetic characters (a-z, A-Z).at(self, index: Int) -> Option<String>: alias forget.byte_length(self) -> Int: returns the number of bytes in the UTF-8 encoding.codepoints(self) -> List<String>: returns each Unicode codepoint as a single-character string in a list.contains?(self, other: String) -> Bool: returnstrueif the string containsotheras a substring.digit?(self) -> Bool: returnstrueif the string contains only numeric characters (0-9).downcase(self) -> String: returns a copy with ASCII uppercase letters converted to lowercase.empty?(self) -> Bool: returnstrueif the string has zero length.ends_with?(self, suffix: String) -> Bool: returnstrueif the string ends withsuffix.graphemes(self) -> List<String>: returns each grapheme cluster as a string in a list. Currently equivalent tocodepoints().join(parts: List<String>, separator: String) -> String: static. Joins a list of strings withseparatorbetween each element.replace(self, old: String, new: String) -> String: replaces all occurrences ofoldwithnew.reverse(self) -> String: returns a copy with the codepoints in reverse order.slice(self, range: Range) -> String: returns a substring spanning the given inclusive range of character indices. Clamps out-of-bounds endpoints.split(self, separator: String) -> List<String>: splits on each occurrence ofseparator. An empty separator splits into individual characters.starts_with?(self, prefix: String) -> Bool: returnstrueif the string starts withprefix.to_binary(self) -> Binary: zero-cost conversion toBinary(every valid UTF-8 string is a valid byte sequence).to_float(self) -> Result<Float, NumericConversionError>: parses the string as a 64-bit float (see Parsing).to_int(self) -> Result<Int, NumericConversionError>: parses the string as a 64-bit signed integer (see Parsing).trim(self) -> String: returns a copy with leading and trailing whitespace removed.trim_end(self) -> String: returns a copy with trailing whitespace removed.trim_start(self) -> String: returns a copy with leading whitespace removed.upcase(self) -> String: returns a copy with ASCII lowercase letters converted to uppercase.whitespace?(self) -> Bool: returnstrueif the string contains only whitespace characters (space,\n,\r,\t).
s = "hello world"
s.length().print() # 11
s.get(0).unwrap().print() # "h"
s.contains?("world").print() # true
s.starts_with?("hello").print() # true
s.split(" ").length().print() # 2
s.upcase().print() # "HELLO WORLD"
s.slice(Range{start: 0, stop: 4}).print() # "hello"
" hello ".trim().print() # "hello"
String also implements Equality (content comparison via ==) and Hash (FNV-1a).
Binary and Bits
Binary represents an arbitrary byte sequence. Bits represents an arbitrary bit sequence. Both are heap-backed value types (copied by reference-counted share like String).
Literals
Binary and bitstring literals use <<>> syntax with comma-separated segments:
header = <<0x48, 0x65, 0x6C, 0x6C, 0x6F>>
wide = <<0x0102::16>>
le = <<0x0102::16 little>>
neg = <<-1::8 signed>>
msg = <<0x01, port::16>>
Segment modifiers: ::N (bit width), ::N byte (byte width), signed/unsigned, big/little, type annotations (: Float32, : Int16). Byte-aligned totals produce Binary, non-byte-aligned produce Bits. String literals can appear as segments for protocol framing.
Binary-typed values splice their bytes into the literal, so a framed message builds in one expression. A bare segment is a splice whenever its value is Binary-typed. payload: Binary spells it out explicitly. Splices take no width or endianness modifiers, and the fixed-width segments around a splice must total whole bytes:
frame = <<0x51, (payload.byte_size() + 4)::32, payload>>
Pattern Matching
Binary patterns destructure byte sequences in match:
match packet
<<tag::8, length::16, rest: Binary>> -> handle(tag, rest)
_ -> "no match".print()
end
Greedy rest capture with rest: Binary consumes all remaining bytes. Patterns that don’t match the data length fall through to the next arm.
Float-extract segments (x: Float32 in a pattern) are not supported yet. When they land, a segment decoding to NaN or infinity will fail the match and fall through to the next arm, Erlang-style, preserving the finite-only float invariant (see Arithmetic Faults).
Functions
at(self, index: Int) -> Option<Int>: returns the byte atindexas anIntin0..255, orOption.Noneout of bounds. O(1). Prefer this overString.getfor scanning large inputs (String.getis O(n) per call because it counts UTF-8 codepoints from the start).byte_size(self) -> Int: returns the number of bytes.slice(self, range: Range) -> Binary: copies the inclusive byte range[start, stop]. Endpoints clamp to the binary’s bounds.to_bits(self) -> Bits: zero-cost widening from bytes to bits.to_string(self) -> Result<String, String.ConversionError>: attempts to interpret bytes as UTF-8, returningInvalidUTF8when decoding fails.
Binary implements Equality (length plus byte comparison, so a == b works) and Hash, making it usable as a Map key or Set element. Its Debug rendering is the byte-list form <<83, 0, 0, 0, 4>>, truncated with a trailing ... past 64 bytes.
Bits functions:
bit_size(self) -> Int: returns the number of bits.byte_at(self, index: Int) -> Option<Int>: returns storage byteindexas anIntin0..255, orOption.Noneout of bounds. Bytes hold bits MSB-first with zeroed trailing padding, and the bitstring occupiesceil(bit_size / 8)bytes. O(1).
Bits also implements Equality (bit length plus bit comparison) and Hash, so it works as a Map key or Set element. Its Debug rendering is the round-trippable literal form: whole bytes as decimals, then any trailing partial byte as value::width, e.g. <<72, 101, 5::3>>. Truncation past 64 bytes matches Binary.
Conversion Functions
String.to_binary(self) -> Binary: zero-cost widening from UTF-8 string to bytes.CPtr<UInt8>.to_binary(self, len: Int) -> Binary: creates aBinaryby copyinglenbytes from the pointer. The pointer is not freed. A negative length panics.Bits.to_binary(self) -> Result<Binary, String>: narrows bits to bytes. ReturnsResult.Errif the bit length is not divisible by 8.
bin = "hello".to_binary()
bits = bin.to_bits()
roundtrip = bits.to_binary().unwrap().to_string().unwrap()
roundtrip.print() # "hello"
File I/O
Fd
A raw file descriptor for low-level I/O:
struct Fd
descriptor: Int
end
Functions:
read(self, count: Int) -> Result<String, String>: reads and validates up tocountbytes as UTF-8.read_binary(self, count: Int) -> Result<Binary, String>: reads up tocountarbitrary bytes.write(self, data: Binary | String) -> Result<Int, String>: writes data, returns bytes written.close(self) -> Result<String, String>: closes the descriptor.
File
Higher-level file operations wrapping Fd:
struct File
fd: Fd
end
Functions:
File.open(path: String, mode: FileMode) -> Result<File, String>: opens a file with the given mode (FileMode.Read,FileMode.Write,FileMode.Append).File.read(path: String) -> Result<String, String>: reads an entire file as UTF-8 text (opens, reads, closes).File.read_binary(path: String) -> Result<Binary, String>: reads an entire file as arbitrary bytes.File.write(path: String, content: Binary | String) -> Result<String, String>: writes text or arbitrary bytes (creates or truncates).File.exists?(path: String) -> Bool: returns true if the file exists.File.delete(path: String) -> Result<String, String>: deletes a file.File.rename(source: String, destination: String) -> Result<String, String>: renames (moves) a file.close(self) -> Result<String, String>: closes the file handle.
content = File.read("config.txt").unwrap()
content.print()
Environment
System.get_env(key: String) -> Option<String>: returns a UTF-8 host value orOption.Nonewhen absent.System.set_env(key: String, value: String): sets a UTF-8 environment value.
Both functions panic when a key or value contains U+0000.
System.get_env also panics if the host value is not valid UTF-8.
Console I/O
IO provides ergonomic console input/output. STDIN, STDOUT, and STDERR are available as Fd constants for low-level access.
Functions:
IO.puts(message: String): writes to stdout with a trailing newline.IO.warn(message: String): writes to stderr with a trailing newline.IO.write(message: String): writes to stdout without a trailing newline.IO.gets(prompt: String) -> String: printspromptand reads a line from stdin (without the trailing newline).
IO.puts("hello")
name = IO.gets("What is your name? ")
IO.puts("Hello, #{name}!")
Parsing
Static functions on Int and Float for parsing strings:
Int.parse(input: String) -> Result<Int, NumericConversionError>: parses a string as a 64-bit signed integer.Float.parse(input: String) -> Result<Float, NumericConversionError>: parses a string as a 64-bit float.
Failures distinguish malformed text from values that don’t fit: NumericConversionError.InvalidFormat for text that isn’t a number, NumericConversionError.OutOfRange for a well-formed number outside the target’s range (an integer overflowing 64 bits, or a float magnitude like 1e999 that would round to infinity). Only finite floats parse. There is no literal syntax for infinities or NaN. This is the same error enum the checked narrowing methods use (see Numeric Widening).
x = Int.parse("42").unwrap()
x.print() # 42
y = Float.parse("3.14").unwrap()
y.print() # 3.14
match Int.parse("99999999999999999999")
Result.Ok(_) -> ()
Result.Err(e) -> e.print() # OutOfRange
end
URI
An RFC 3986 URI, parsed into its components. Fields hold the encoded (wire-form) text exactly as it appears in the URI. Every URI has a path (possibly empty), so path is not optional:
struct URI
fragment: Option<String>
host: Option<String>
path: String
port: Option<Int>
query: Option<String>
scheme: Option<String>
userinfo: Option<String>
end
Functions:
URI.parse(input: String) -> Result<URI, URI.Error>: parses and validates an absolute or relative URI. The scheme is lowercased, and a known scheme’s default port fillsportwhen the input has none. Errors carry the offending part of the input.to_string(self) -> String: reassembles the URI, omitting the port when it equals the scheme’s default.URI.encode(input: String) -> String: percent-encodes every character that is neither reserved nor unreserved.URI.decode(input: String) -> Result<String, URI.Error>: percent-unescapes, rejecting malformed%XXsequences and invalid UTF-8.URI.default_port(scheme: String) -> Option<Int>: the well-known port for a scheme ("https"gives443), orOption.None.
URI implements Equality (component-wise) and Debug (format renders the assembled URI string, so interpolation produces the URL).
uri = URI.parse("https://example.com/pkg?v=1").unwrap()
uri.host.unwrap().print() # "example.com"
uri.port.unwrap().print() # 443
"fetching #{uri}".print() # "fetching https://example.com/pkg?v=1"
URI.encode("put it+й").print() # "put%20it+%D0%B9"
Base
RFC 4648 encoding and decoding: base16 (hex), base64, and url-safe base64. Encoders accept either a String (encoded as its UTF-8 bytes) or a Binary, and return the encoded text. Decoders take a String and return the decoded bytes, or a Base.Error (InvalidCharacter with the offending character, InvalidLength, or InvalidPadding).
Base.encode16(data: Binary | String) -> String: lowercase hex, two characters per byte.Base.decode16(text: String) -> Result<Binary, Base.Error>: accepts both cases.Base.encode64(data: Binary | String) -> String: standard+/alphabet, padded with=.Base.decode64(text: String) -> Result<Binary, Base.Error>Base.url_encode64(data: Binary | String) -> String: url-safe-_alphabet, padded with=.Base.url_decode64(text: String) -> Result<Binary, Base.Error>
Base64 decoders accept both padded and unpadded input, but = may only appear as final padding:
Base.encode64("foobar").print() # "Zm9vYmFy"
Base.decode64("Zm9vYg==").unwrap().print() # <<102, 111, 111, 98>>
Base.decode64("Zm9vYg").unwrap().print() # <<102, 111, 111, 98>>
Base.encode16(<<0, 15, 255>>).print() # "000fff"
Base.url_encode64(<<251, 239>>).print() # "--8="
Path
POSIX path manipulation, modeled on Elixir’s Path. All functions are pure string operations except expand, which reads the current working directory and HOME. None of them touch the file system, so .. resolution is lexical and assumes no symlinks.
Path.absolute?(path: String) -> Bool:truewhen the path starts with/.Path.basename(path: String) -> String: last component, ignoring a trailing slash. The root/has an empty basename.Path.dirname(path: String) -> String: directory component. A path without a separator gives., and a trailing slash counts as a separator ("foo/bar/"gives"foo/bar").Path.extname(path: String) -> String: extension of the last component including the dot, or"". A leading-dot file such as.gitignorehas no extension.Path.rootname(path: String) -> String: the path with its extension stripped.Path.join(parts: List<String>) -> String: joins segments, collapsing duplicate separators and stripping a trailing slash. Empty segments are skipped, and an empty list gives"".Path.split(path: String) -> List<String>: path components. An absolute path’s first component is"/", and""gives an empty list.Path.expand(path: String) -> String: absolute path with.and..resolved. A leading~or~/expands toHOME(left literal when unset), and relative paths resolve against the working directory.Path.relative_to(path: String, base: String) -> String: path frombasetopath. Two relative paths give a minimal path that may walk up with.., two absolute paths only strip a shared prefix, andpathis returned (normalized) whenbaseis not a prefix or the kinds are mixed.
Path.join(["/usr", "local/", "bin"]).print() # "/usr/local/bin"
Path.extname("archive.tar.gz").print() # ".gz"
Path.expand("/foo/bar/../baz").print() # "/foo/baz"
Path.split("/foo/bar").print() # ["/", "foo", "bar"]
Path.relative_to("tmp/foo/bar", "tmp/bat").print() # "../foo/bar"
Enumeration<T> Protocol
protocol Enumeration<T>
fn length(self) -> Int
fn get(self, index: Int) -> Option<T>
end
Any type implementing Enumeration<T> can be used with for loops. List<T> and String implement this protocol. get returns Option<T> instead of panicking on out-of-bounds access. for loops unwrap the Option automatically.
Equality Protocol
protocol Equality
fn eq(self, other: Self) -> Bool
end
Powers the == and != operators. Implemented for all numeric types, Bool, String, Binary, and Bits.
Hash Protocol
protocol Hash
fn hash(self) -> Int
end
Required for keys in Map<K, V> and elements in Set<T>. Implemented for all numeric types, Bool, String, Binary, and Bits. Integers use SplitMix64, and strings and binaries use FNV-1a.
Bitwise Protocol
protocol Bitwise
fn band(self, other: Self) -> Self
fn bor(self, other: Self) -> Self
fn bxor(self, other: Self) -> Self
fn bnot(self) -> Self
fn bsl(self, n: Int) -> Self
fn bsr(self, n: Int) -> Self
end
Bitwise operations are methods rather than symbolic operators. Koja reserves <</>> for binary literals, | for union types, and & for protocol composition in trait bounds. All integer types implement Bitwise.
bsl and bsr panic when the shift count is negative or at least the receiver’s bit width (1.bsl(64) on an Int), matching the arithmetic fault contract. The other four operations never fault.
flags = 0b1010
(flags.band(0b1100)).print() # 8 (0b1000)
flags.bor(0b0001).print() # 11 (0b1011)
1.bsl(4).print() # 16
16.bsr(4).print() # 1
Debug Protocol
protocol Debug
fn format(self) -> String
fn print(self) # default: IO.puts(self.format())
fn inspect(self) -> Self # default: prints, then returns self
end
format returns a round-trippable string representation of the value. print writes that string to stdout (via IO.puts). The receiver stays live and the call returns (). inspect is the chainable variant. It prints and returns self, useful for tap-style debugging in the middle of an expression. The compiler auto-derives Debug for all types: primitives via intrinsics, enums as VariantName or VariantName(payload), structs as TypeName{field: value, ...}. Generic types derive the same full field-by-field body as concrete ones. Fields whose type has no meaningful rendering (CPtr<T>, function values) render as a literal "..." placeholder. Implementing format is enough to get print and inspect for free. Custom implementations can override the derived one via impl Debug for MyType.
Debug.format for String is round-trippable: it wraps the contents in double quotes and escapes \, ", \n, \r, \t. That means .print() shows top-level strings quoted, and aggregates render their String fields quoted too:
p = Point{x: 1, y: 2}
p.print() # Point{x: 1, y: 2}
"point is #{p}".print() # "point is Point{x: 1, y: 2}"
"n = #{42}".print() # "n = 42"
"hello".print() # "hello"
User{name: "alice"}.print() # User{name: "alice"}
For raw, unquoted output use IO.puts directly (it writes its String argument verbatim and adds a newline):
IO.puts("hello") # hello
IO.puts(p.format()) # Point{x: 1, y: 2}
Literal Protocols
List and map literals are backed by protocols, allowing custom types to opt into literal syntax.
ListLiteral<T>: the compiler builds a List<T> from [a, b, c] and passes it to from_list:
protocol ListLiteral<T>
fn from_list(list: List<T>) -> Self
end
List<T> and Set<T> implement ListLiteral<T>.
MapLiteral<K, V>: the compiler builds a Map<K, V> from [k: v, ...] and passes it to from_map:
protocol MapLiteral<K, V>
fn from_map(map: Map<K, V>) -> Self
end
Map<K, V> implements MapLiteral<K, V>.
C FFI
Koja can call C functions via the @extern "C" annotation. FFI declarations live on structs (types are namespaces). No unsafe keyword. Safety is the wrapper author’s responsibility.
Declaring Extern Functions
@extern "C" on a function marks it as a C declaration. @link "libname" tells the linker which library provides the symbol (-l libname). Extern functions live inside structs, which serve as namespaces.
struct FFI
@extern "C" @link "mylib"
fn add_numbers(a: Int32, b: Int32) -> Int32
@extern "C" @link "mylib"
fn fill_buffer(buf: CPtr<Int32>, count: Int32, value: Int32)
end
result = FFI.add_numbers(3, 4)
result.print()
Extern functions have no body. Parameter and return types must be FFI-compatible: explicit-width primitives (Int32, UInt8, Float32, etc.), Bool, CPtr<T>, or (). Extern functions can coexist with normal Koja functions in the same struct. Use priv fn on the extern declarations and expose safe public wrappers.
A Float32 / Float64 value returned by an extern call is checked at the call site: a NaN or infinity handed back by C panics with an ArithmeticError (non-finite float returned by <name>), keeping the finite-only float invariant intact across the FFI boundary (see Arithmetic Faults).
Declare C return types at their true width and let numeric widening do the rest. A C int bound as Int32 flows directly into Int contexts with correct sign extension, so negative error codes survive the trip. Reading a C int as Int would zero-extend the upper 32 bits and corrupt negative values.
Symbol Naming
When the C symbol name differs from the Koja function name, use @link "lib:symbol" to specify the C symbol after a colon:
struct Crypto
@extern "C" @link "crypto:EVP_sha256"
priv fn evp_sha256() -> CPtr<UInt8>
@extern "C" @link "crypto:SHA256"
priv fn sha256_raw(data: CPtr<UInt8>, len: Int64, out: CPtr<UInt8>) -> CPtr<UInt8>
end
@link "crypto" (without a colon) uses the Koja function name as the C symbol. @link "crypto:SHA256" links to the C symbol SHA256 while the Koja function name is sha256_raw. This keeps all Koja function names in proper snake_case regardless of the C library’s naming conventions.
CPtr<T>
A raw C pointer type. Copy semantics (just a machine word). No ownership tracking. The compiler will not auto-free memory behind a CPtr<T>.
struct CPtr<T>
fn null() -> CPtr<T>
fn alloc(count: Int) -> CPtr<T>
fn free(self)
fn offset(self, n: Int) -> CPtr<T>
fn read(self) -> T
fn write(self, value: T)
fn null?(self) -> Bool
end
alloc and free use C’s malloc and free. All methods are compiler intrinsics.
buf: CPtr<Int32> = CPtr.alloc(4)
buf.write(42)
buf.read().print()
buf.free()
null_ptr: CPtr<Int32> = CPtr.null()
null_ptr.null?().print()
Type annotations on the variable drive generic inference for static methods like CPtr.alloc() and CPtr.null().
CPtr<UInt8> additionally provides the two ways to get a pointer to a Binary’s bytes:
CPtr.borrow(bytes: Binary) -> CPtr<UInt8>: zero-cost view of the binary’s payload. The result cannot be bound to a variable, returned, or stored. It may only be consumed within the statement that borrows it (as a call argument or chained receiver), where the sourceBinaryis guaranteed to be live.CPtr.copy(bytes: Binary) -> CPtr<UInt8>: malloc’d owned copy of the bytes. Nameable like any value. The caller frees it. Use this when a C API retains the pointer past the call.
digest = CPtr.alloc(32)
FFI.blake3_hash(CPtr.borrow(data), data.byte_size(), digest) # fine
p = CPtr.borrow(data) # compile error: a borrowed pointer cannot be bound
owned = CPtr.copy(data) # owned copy, free it when C is done
CString
A pointer-and-length descriptor for a null-terminated C string. It does
not encode ownership. String.to_cstring() allocates owned memory, while
CPtr<UInt8>.to_cstring() wraps an existing pointer without allocating.
struct CString
ptr: CPtr<UInt8>
len: Int
end
enum CString.ConversionError
InteriorNul
InvalidLength
InvalidUTF8
NullPointer
end
Convert between Koja strings and C strings:
name = "hello"
cs = name.to_cstring().unwrap()
cs.len.print()
back = cs.to_string().unwrap()
(back == name).print()
cs.free()
String.to_cstring() -> Result<CString, CString.ConversionError>
allocates a null-terminated copy via malloc and rejects String
values containing U+0000 with InteriorNul.
CString.to_string() -> Result<String, CString.ConversionError> copies
exactly len bytes and rejects invalid lengths, pointers, and UTF-8.
It does not consume or free the C buffer. Call free() only when the
descriptor owns malloc-compatible storage.
Passing Pointers to C
CPtr<T> is accepted in @extern "C" signatures, enabling pointer-passing FFI:
struct FFI
@extern "C" @link "mylib"
fn fill_array(buf: CPtr<Int32>, count: Int32, value: Int32)
@extern "C" @link "mylib"
fn sum_array(buf: CPtr<Int32>, count: Int32) -> Int32
end
buf: CPtr<Int32> = CPtr.alloc(4)
FFI.fill_array(buf, 4, 10)
total = FFI.sum_array(buf, 4)
total.print()
buf.free()
For string-accepting C functions, pass cs.ptr (the CPtr<UInt8>) and cs.len:
cs = "hello".to_cstring().unwrap()
FFI.some_c_function(cs.ptr, cs.len)
cs.free()
For byte-accepting C functions, borrow a pointer to the Binary at the call site:
FFI.consume_bytes(CPtr.borrow(data), data.byte_size())
Pointers passed to C are valid for the duration of the call. A C function that keeps the pointer past the call needs CPtr.copy (an owned copy the caller frees).
Annotations
@doc
Documents a function, struct, or enum:
@doc "Adds two integers."
fn add(a: Int32, b: Int32) -> Int32
a + b
end
@doc false excludes an item from generated documentation.
@doc on a priv declaration is a compile error, since private items never appear in generated documentation.
Doc strings support Markdown and are rendered by koja doc.
@test
Marks a function as a test case. koja test discovers and runs all
@test-annotated functions in src/ and test/ directories. Test
functions return Result<Bool, String>. Any Result.Ok passes, while
Result.Err(message) fails with the given message.
struct AdditionTest
@test "adds two integers"
fn test_addition -> Result<Bool, String>
result = add(2, 3)
unless result == 5
return Result.Err("expected 5, got #{result}")
end
Result.Ok(true)
end
end
An optional string after @test provides a description printed during the
test run. The runner reports every discovered test even when some fail.
Tooling
| Command | Description |
|---|---|
koja new |
Scaffold a new project directory |
koja build |
Compile to a native binary via LLVM |
koja run |
Build and execute in one step |
koja check |
Type check without compiling |
koja test |
Run @test-annotated functions |
koja deps |
Fetch and inspect dependencies (get, update) |
koja format |
Opinionated code formatter (--write, --check) |
koja doc |
Generate static HTML documentation |
koja lex |
Dump tokens |
koja parse |
Dump AST |
Project Scaffolding
koja new <name> creates a project directory with the following structure:
my_app/
koja.toml
src/
app.koja
The koja.toml file defines the project configuration:
[project]
entry = "App"
koja = "0.15"
name = "my_app"
version = "0.1.0"
Fields:
name: project name (used as the binary output name).version: semantic version string.entry: the type implementingProcessthat the program starts (required forbuild/run).src: source directories (default["src"]).test: test directories (default["test"]).koja: minimum compiler version, e.g.koja = "0.15.0". A bare version, no operators. An older compiler refuses the package (and any package depending on it) with an error naming both versions.
A [dependencies] table declares path and git dependencies; see Dependencies.
Language Server (LSP)
Real-time diagnostics, document formatting, hover (type signatures + @doc), and go-to-definition. Integrates with VS Code / Cursor via a bundled extension.
Formatter
Zero-config, opinionated. koja format --write reformats in place, koja format --check exits non-zero if formatting differs. The formatter handles escape re-encoding for round-trip correctness and preserves annotations.