Language Reference
The Lingo Reference
Lingo is a statically-typed functional language with algebraic effects, structured concurrency, and first-class capabilities. It is designed for AI agents to author and humans to read - low ambiguity, high signal, exactly one obvious way to do anything.
Pre-v1. The language is in active development. This reference describes what is shipped and runnable today; every snippet below is real Lingo. Run any of it in the playground.
On this page
Overview & philosophy
Most languages optimize for human ergonomics: terseness, memorable syntax, clever shortcuts. When the primary author is a machine, those tradeoffs invert. Lingo drops them and uses the room that opens up.
Design mandates
- AI-author, human-reader. Optimised for reliable generation and for auditing - hallucinated names, lost references, and hidden side effects are made structurally less likely.
- One canonical form. Exactly one idiomatic way to do anything. When two forms accumulate, the language removes one rather than aliasing it.
- Immutability by default. All bindings and data are immutable.
varrebinds a name; the value it pointed at is untouched. There is nomut. - Effects in the type. A function's signature declares everything it can do. The compiler enforces the call graph: a caller must declare any effect its callees carry.
- Deterministic by default. Time and randomness are tracked effects; tests install virtual clocks and seeded sources automatically, so runs reproduce.
- Machine-first diagnostics. The compiler emits structured JSON; the human-readable CLI is a thin wrapper over it.
The working test for every design decision is the same: would an agent actually reach for this? The rest of this reference is the answer, section by section.
// A pure function carries no effect row.
fn shout(msg: String) -> String {
msg.to_upper()
}
// The signature declares everything run can do.
fn run() -> () ! IO {
Console.log(shout("hello, lingo"))
}
Syntax & literals
Identifiers are [a-zA-Z_][a-zA-Z0-9_]*. Types and variants are PascalCase; values and functions are snake_case.
One marker, one role
Every syntactic marker carries exactly one semantic role, so reading a token never requires disambiguation.
| Marker | Role | Example |
|---|---|---|
| = | Value binding & rebinding | let x = 1, T { f = v }, f(name = v) |
| : | Type / structural assertion | let x: Int, fn f(x: Int) |
| -> | Code-flow arrow | fn f(...) -> R, (p) -> body, pat -> body |
| :: | Type-associated access | User::from_json(j), Port::try_from(v) |
| ! | Effect label / logical NOT | fn f() -> () ! IO, !true |
| ? | Try / propagate (postfix) | let body = Http.get(url)? |
| ?. ?: | Safe-nav / default-on-None | opt?.field, opt ?: default |
| |> | Pipe (data-first chaining) | x |> double() |
| ++ | Concatenation | "hi " ++ name, a ++ b |
| .. | Spread / rest / range | [h, ..rest], 0..5 |
Literals
- Int -
42,1_000_000, hex0xFF, binary0b1010. 64-bit signed; overflow is a runtime error. UseBigInt(123n) for arbitrary precision. - Decimal -
3.14,1.0e-9. The default for any literal with a dot or exponent; exact, arbitrary-precision arithmetic. - Float - IEEE 754 double, written with the
fsuffix:1.5f. A bare fractional literal flows into any Float context by inference. - String -
"hello\n"with${expr}interpolation and${expr:spec}formatting. Raw strings use backticks and span newlines verbatim. - Bytes -
b"deadbeef"(hex) orb"hello"(ASCII), parsed at compile time. - Tagged literals - compile-time-parsed and validated:
re`\d+`(Regex),json`{"k": 1}`(JsonValue),lson`Point { x = 1 }`. - Unit -
()in all three positions: type, value, and pattern.
Strings: two delimiters, one rule
"..." is processed (escapes and ${} interpolation). `...` is raw (verbatim, may span newlines, may contain bare quotes) - the canonical form for embedded JSON, regex, and multi-line text.
Comments
// single-line, /* ... */ nestable block, /// declaration doc-comment, //// module doc-comment.
Type system
Lingo uses a bidirectional type system with Hindley-Milner inference. Primitive types are Int, Float, Decimal, String, Bool, Bytes, (), and Never (the bottom type).
Records
Nominal product types with named fields. Declaration uses : (type assertion); construction uses = (value binding). Field order is free; the typechecker reorders.
pub type Address { city: String, country: String }
pub type User {
id: Int,
name: String,
address: Address,
}
let user = User {
id = 1,
name = "Alice",
address = Address { city = "New York", country = "US" },
}
// Auto-derived: fresh record with overrides (the value is never mutated).
let renamed = user.copy(name = "Bob")
Every record auto-derives .copy(field = v), JSON serialisation (.to_json() / T::from_json(s)), argv parsing (T::from_argv(argv)), and .to_string() - no boilerplate, no annotations.
Sum types
type Color { Red; Green; Blue } // nullary
type Shape { Circle(Float); Rectangle(Int, Int) } // positional
type Result<T, E> { Ok(T); Err(E) } // generic
type Option<T> { None; Some(T) } // prelude
Option<T> and Result<T, E> are in the prelude; Some / None / Ok / Err are always in scope.
Tuples and T?
Tuples are fixed-size ordered collections: let pair: (Int, String) = (1, "hi"). The postfix T? is pure sugar for Option<T> at any type position - it is an Option, not a separate nullable concept. Construct with Some(x) / None.
fn lookup(k: String) -> User? { ... } // canonical postfix
type Account { id: Int, email: String? } // record field
Bytes & fixed-size arrays
Bytes is the opaque carrier for binary data - file contents, payloads, crypto input - sidestepping the boxing of List<Int> and the UTF-8 validation of String. Array<T, N> is a fixed-size array with a type-level length that threads through method return types.
Functions, bindings & matching
Lingo is expression-oriented; most constructs evaluate to a value. let bindings are immutable and can be shadowed for pipeline narrowing. Function parameters support defaults and named arguments.
fn add(x: Int, y: Int) -> Int { x + y }
fn fetch(url: String, timeout: Int = 5000, follow: Bool = true)
-> Result<String, String> ! IO {
// ...
}
fetch("https://example.com") // defaults all
fetch("https://example.com", timeout = 30000) // override one
Lambdas
Arrow form (x, y) -> x + y, typed form fn(x: Int) -> Int { x * 2 }, and trailing-lambda syntax xs.fold(0) { (acc, x) -> acc + x }.
if and match
Both are expressions; every branch returns the same type, and if in expression position requires an else. There is no if let - match is canonical for conditional pattern-binding.
let outcome = match Http.get(url) {
Ok(resp) where resp.status == 200 -> "ok: ${resp.body.len()} bytes"
Ok(resp) -> "status ${resp.status}"
Err(e) -> "failed: ${e.message}"
}
match xs {
[] -> "empty"
[a] -> single(a)
[h, ..t] -> cons(h, t)
}
Patterns support structural destructuring, where guards, and or-patterns (1 | 2 | 3). The compiler enforces exhaustiveness: every case in the scrutinee's domain must be covered, or the missing witness is reported.
Iteration
var + for + while is the primary iteration story; tail fn is for structural recursion. var declares a rebindable name - = rebinds it - but values stay immutable, so there are no aliasing surprises.
var total = 0
for x in xs {
if x % 2 == 0 { continue } // skip evens
total = total + x
}
var n = start
while n != 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 }
}
break and continue act on the innermost loop; return expr exits the enclosing function. while loops and tail-call loops are guarded by a runtime iteration watchdog.
Method dispatch
Field-access dispatches through the stdlib namespace, and user free functions whose first parameter matches the receiver dispatch by the same uniform-function-call rule. Construction has exactly three surfaces: namespace calls (Bytes.from_hex(s)), type-associated calls (User::from_json(s), Port::try_from(v)), and tagged literals.
Operators
Arithmetic + - * / % is checked - overflow and divide-by-zero are runtime errors, never silent wrapping. There are no implicit numeric coercions: Int + Decimal is a type error; convert explicitly with .to_decimal() / .to_float().
| Group | Operators |
|---|---|
| Try family | ? (propagate), ?. (safe-nav), ?: (default), T? (Option sugar) |
| Pipe | x |> double() |> inc() |
| Concat | ++ on String, List, Bytes |
| Arithmetic | + - * / % (checked) |
| Bitwise | & | ^ << >> and unary ~ on Int / BigInt |
| Comparison | == != (structural), < <= > >= |
| Boolean | && || ! (short-circuit) |
All three try sigils accept both Option<T> and Result<T, E> receivers. The pipe threads its left value as the first argument of the right-hand call.
Effects & handlers
A function declares its effects after !. A signature with no effect row is pure. If f calls g and g carries effect E, then f must declare E too - the compiler enforces call-graph consistency, so an effect can never hide from a caller.
fn fetch(url: String) -> Result<String, String> ! IO + Net { ... }
fn compute(x: Int) -> Int { ... } // pure: no effect row
Built-in effect labels include IO, FS, Net, Time, Random, Async, Process, Log, Trace, and LLM. Programs define their own with an effect declaration.
Effect handlers
Algebraic handlers intercept effect operations - the primary mechanism for dependency injection and testing. The same code runs against a real implementation or a mock by swapping the handler.
effect Database {
fn query(sql: String) -> Result<JsonValue, String>
}
fn get_email(id: Int) -> Result<String, String> ! Database {
let row = query("SELECT email FROM users WHERE id = ${id}")?
Ok(row.get("email")?.as_str() ?: "unknown")
}
test "get_email returns mock data" {
with handler {
query(sql) -> { Ok(json`{ "email": "mock@example.com" }`) }
} {
let email = get_email(1).unwrap()
assert.eq(email, "mock@example.com")
}
}
Handler arms can resume the call site (ctl ops bind a resume function) or early-exit the whole block by not resuming. Handlers compose with structured concurrency - a handler wrapping a concurrent block propagates into spawned tasks.
Capabilities
Effects say what kind of thing a function does; capabilities say whether it was granted permission. A function that takes a Cap<T> value can only act within that grant. Combined with effects-in-type, this enforces the Principle of Least Authority structurally - the foundation for safely running untrusted, AI-authored modules.
fn run(cap: Cap<Root>) -> Int ! IO {
let fs = cap.fs().scope_path("/tmp").read_only()
let body = read_under("/tmp/log", fs)
Console.log(body)
0
}
fn read_under(path: String, cap: Cap<FS>) -> String { ... }
The runtime constructs a root capability and passes it to run(cap: Cap<Root>). From there code derives narrower caps - cap.fs(), cap.net(), cap.time() - and scopes them further (scope_path restricts to a path prefix; read_only disables writes). Filesystem operations check the cap's restrictions before they run; a violation raises CapabilityDenied.
The two tiers compose: a one-shot script uses ambient effects (fn run() with ! IO) and never threads a cap; a marketplace module or sandboxed task takes explicit cap values where verifiable scope matters.
Exit codes
The return value of run() is the program's exit signal - there is no imperative Process.exit. fn run() -> Result<String, String> returning Ok prints to stdout with exit 0; Err prints to stderr with exit 1. A returned ExitCode sets the code explicitly.
Refinement types
Refinement types attach a compile-time-checked predicate to a base type. They replace human-readable docstrings with machine-verified contracts: the constraint isn't a comment a reader might miss, it's a property the compiler proves.
type Port = Int where 0 <= x <= 65535
type Email = String where len > 5
type Name = String where 1 <= len <= 32
let p: Port = 80 // accepted at typecheck
let bad: Port = 99999 // rejected at typecheck
// Non-literal values narrow through a checked cast.
let port = Port::try_from(value)? // Result<Port, RangeError>
The subject is x for Int refinements and len for String / List / Set / Bytes. Subtyping follows interval containment, so SmallPort <: Port. In read positions a refined value widens to its base type automatically; re-narrowing is an explicit try_from.
The core checker proves linear-arithmetic intervals and length bounds with a built-in solver. The full tier widens the predicate sublanguage to disjunction, negation, and inequality, dispatching the cases its structural solver can't decide to an SMT backend; lingo prove runs the full pass over every obligation and reports a counter-example when a predicate fails.
Structured concurrency
A concurrent block waits for all tasks it spawns - no task outlives its scope, so there are no orphans. Callback effects propagate to the spawn site.
fn parallel_sum(xs: List<Int>) -> Int ! Async {
concurrent { c ->
let mid = xs.len() / 2
let t1 = c.spawn(() -> Int { xs.take(mid).fold(0, (a, b) -> a + b) })
let t2 = c.spawn(() -> Int { xs.drop(mid).fold(0, (a, b) -> a + b) })
t1.await() + t2.await()
}
}
c.spawn(f)enqueues a closure and returns aTask<T>;t.await()joins it.xs.concurrent_map(f)fans out one task per element, gathered in input order; first error wins.max_concurrent = Ncaps in-flight tasks.Channel.bounded(N)gives a channel withsend/recv/close;recvreturnsNoneonce closed and drained.- A
raceblock returns the first task to produce a result.
Auto-derived schemas
Every record whose fields are all convertible auto-derives JSON serialisation and a schema - no @derive, no keyword, no boilerplate.
type User { id: Int, name: String, email: String? }
let alice = User { id = 42, name = "alice", email = Some("alice@x.io") }
let json: String = alice.to_json()
let decoded: Result<User, ValidationError> = User::from_json(json)
Decoding is strict by default: unknown keys and missing required fields produce a ValidationError carrying the offending JSON path. Optional fields (T?) default to None. Sum types use an internally-tagged encoding (a _tag discriminator). Record annotations tune the behaviour: @lenient ignores unknown keys, @version("N") round-trips a version field, @discriminator("kind") renames the tag.
The same machinery powers argv parsing: every eligible record derives T::from_argv(argv), mapping max_results to --max-results, with a built-in --help and did-you-mean suggestions.
Errors
The error path is Result<T, E> everywhere. For richly-contextualised failures, return Result<T, RuntimeError> and add context as the error propagates - the source location is filled in automatically at the .context() call site.
fn parse_config() -> Result<Int, RuntimeError> {
read_file_bytes().context("opening config.toml")?
.to_string_utf8().context("decoding UTF-8")?
.parse_int().context("parsing port number")
}
err.summary() renders the innermost cause for a CLI; err.detail() renders the full context chain with source location. panic() is the unrecoverable "the universe broke" path - recoverable failures always use Result, and there is no catch / recover language primitive.
Testing
Tests are part of the language, not a separate runner. Inline test "name" { ... } blocks sit next to the code they cover and run with lingo test - no configuration, no imports, no framework to choose.
fn add(a: Int, b: Int) -> Int { a + b }
test "addition is commutative" {
assert.eq(add(2, 3), add(3, 2))
}
test "zero is the identity" {
assert.eq(add(5, 0), 5)
assert.is_true(add(1, 1) == 2)
}
The assertion surface is assert.eq / ne / is_true / is_false / is_ok / is_err / contains / matches. Lingo is Result-first, so failing paths assert with is_err; assert.raises is the secondary path for code that panics rather than returning Err.
Effects are mocked the same way they are handled everywhere - by installing a handler (see Effects), so there is no separate dependency-injection layer. The Test.Mock factories - mock_time, mock_random, mock_fs, mock_net, mock_io, and more - pin an effect to fixed values for the scope of a with block, which makes time- and IO-dependent code deterministic under test.
fn timestamp_label() -> String ! Time {
"logged at " ++ Time.now().to_string()
}
test "timestamp is deterministic under a mocked clock" {
with Test.Mock.mock_time(42) {
assert.eq(timestamp_label(), "logged at 42")
}
}
Test bodies also install a virtual clock and a seeded random source automatically, so any test that reads Time or Random is reproducible with no setup. Property-based tests generate a hundred cases and report a minimal counter-example on failure.
test for_all x: Int, y: Int {
assert.eq(x + y, y + x)
}
test for_all s: String {
assert.eq(s.len(), s.len())
}
Modules & imports
Top-level fn, type, and let are module-private by default; pub exports them across module boundaries. Imports do not re-export - a module that imports a name does not pass it on transitively.
target native
// The stdlib is ambient - Console, List, Http resolve with no import.
// `import` is only for a local module or a remote package:
import numbers // local module: numbers.double(n)
import greet {salutation, farewell} // local brace-import: bare names in scope
import lingo-lang.org/packages/hello@0.3.0 as Hello // remote package: Hello.greet(...)
The standard library is ambient: a Capitalized leading token (Console, List) resolves against the stdlib catalog with no import line, so import is only ever a local project module (file path = module path) or a remote package. Duplicate and unused imports are hard errors, not warnings, so import cruft is caught at compile time. Cyclic imports are rejected with the cycle path named.
A remote package's canonical form is the unquoted import <host>/<ns>@<ver> as Name - the dotted hostname is the external-dependency signal, and members are reached as Name.member. It fetches and caches by version, verified against a publisher-asserted SHA-256 with trust-on-first-use in a project lockfile. HTTPS is the default; write http:// explicitly (which requires quotes, the only case that needs them) for a non-SSL host. A remote .lson data file can be imported with a content-hash pin over its evaluated value, so cosmetic edits keep the pin valid but a value change breaks it loudly.
Targets
A source file declares the execution substrates it supports. The CLI's --target must be covered by one of the declarations.
target native // any native platform
target native {linux, macos}
target server {node} // Node.js
target web {browser} // browser
target web {wasm} // WebAssembly via Wasm-GC
| Category | Default platforms | Capabilities |
|---|---|---|
| native | linux, macos, windows | full stdlib |
| server | node | full stdlib |
| web | browser | full stdlib except Fs, Process, Env |
Projects scale from a bare script (myscript.lingo) through single-file and multi-file layouts to modules-as-boundaries, where pub visibility is the only mechanism needed to draw context seams. Multi-file projects lift target into a single targets field of the project manifest.
Standard library
The core namespaces are ambient after a target line - Console.log(...), Json.encode(...), and the collection methods work with no explicit import. The table below is a map of the surface; reach for the playground for full method signatures.
| Namespace | Purpose |
|---|---|
| Console | log / print / message / read (stdout, stderr, stdin) |
| List | map, filter, fold, find, sort, min/max, zip, range, join, with_index |
| String | split, trim, replace, find, slice, chars, parse_int; byte- and codepoint-indexed families |
| Map / Set | insert, get, has, keys, values, union, intersection, difference |
| Int / Float / Decimal / BigInt | arithmetic, formatting, transcendentals; exact Decimal and arbitrary-precision BigInt |
| Json | encode / decode / query (jq-subset paths) / from_map |
| Fs / Process / Env | filesystem ops, subprocess, args / vars (effectful) |
| Http | get / post / request (! Net) |
| Time / Instant / Duration | virtual-clock time, instants, durations with parse / format |
| Scope / Task / Channel / Stream | structured concurrency, channels, lazy streams |
| Bytes / Base64 / Compress / Crypto | binary buffers, encoding, gzip, hashing & signing |
| Regex / Unicode / Char / Html | RE2 patterns, normalization, character classes, entity escaping |
| Heap / Array | priority queue, fixed-size arrays |
Cheatsheet
let xs = [1, 2, 3]
let doubled = xs.map(x -> x * 2)
let evens = xs.filter(x -> x % 2 == 0)
let total = xs.fold(0, (acc, x) -> acc + x)
let scores = #["Alice" = 10, "Bob" = 20]
let alice = scores.get("Alice") ?: 0
let primes = $[2, 3, 5, 7, 11]
let has7 = primes.contains(7)
let greeting = "Hello, ${name}!"
let formatted = "Price: ${price:.2}" // 2 decimals
let padded = "${42:>5}" // right-align width 5
let hex = "0x${255:08x}" // zero-padded hex
let csv = ["a", "b", "c"].join(",")
let parts = "a,b,c".split(",")
let name = opt_name ?: "Guest" // default
let upper = opt_name?.to_upper() // safe navigation
let val = result.unwrap_or_else(e -> -1)
let data = get_data()? // propagate
Non-goals
Lingo deliberately excludes features that lower the signal of the authoring surface. The omissions are as load-bearing as the inclusions.
- Reference-level mutability. Values are permanently immutable;
varrebinds names,.copy()produces fresh records. No&mut, no in-place mutation. - Inheritance. Nominal records, sum types, and composition only - no class hierarchies.
- Function and operator overloading. One canonical name per scope; built-in operator allowlists per type.
- Implicit coercions.
Int + DecimalandInt + Floatare type errors; convert explicitly. Numeric literals do promote in typed contexts, but variables never auto-convert. - Turing-complete macros. No compile-time metaprogramming that obscures intent.
- Effect inference without declaration. Every function declares its effect row; there is no whole-program inference.
- User-facing panic recovery. Recoverable failures use
Result; non-local control uses effect handlers.panic()propagates to the runtime boundary. - Dependent types. Refinement types cover the ground Lingo needs - proof power is traded for deterministic, explicit execution.
Ready to write some? Open the playground and run any snippet on this page.