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
00

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. var rebinds a name; the value it pointed at is untouched. There is no mut.
  • 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.

hello.lingo
// 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"))
}
01

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.

MarkerRoleExample
=Value binding & rebindinglet x = 1, T { f = v }, f(name = v)
:Type / structural assertionlet x: Int, fn f(x: Int)
->Code-flow arrowfn f(...) -> R, (p) -> body, pat -> body
::Type-associated accessUser::from_json(j), Port::try_from(v)
!Effect label / logical NOTfn f() -> () ! IO, !true
?Try / propagate (postfix)let body = Http.get(url)?
?. ?:Safe-nav / default-on-Noneopt?.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, hex 0xFF, binary 0b1010. 64-bit signed; overflow is a runtime error. Use BigInt (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 f suffix: 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) or b"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.

02

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.

records.lingo
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

sums.lingo
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.

optionals.lingo
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.

03

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.

functions.lingo
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.

matching.lingo
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.

iteration.lingo
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.

04

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().

GroupOperators
Try family? (propagate), ?. (safe-nav), ?: (default), T? (Option sugar)
Pipex |> 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.

05

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.

effects.lingo
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.

handlers.lingo
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.

06

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.

capabilities.lingo
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.

07

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.

refinement.lingo
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.

08

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.

concurrency.lingo
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 a Task<T>; t.await() joins it.
  • xs.concurrent_map(f) fans out one task per element, gathered in input order; first error wins. max_concurrent = N caps in-flight tasks.
  • Channel.bounded(N) gives a channel with send / recv / close; recv returns None once closed and drained.
  • A race block returns the first task to produce a result.
09

Auto-derived schemas

Every record whose fields are all convertible auto-derives JSON serialisation and a schema - no @derive, no keyword, no boilerplate.

schemas.lingo
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.

10

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.

errors.lingo
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.

11

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.

testing.lingo
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.

determinism.lingo
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.

property.lingo
test for_all x: Int, y: Int {
  assert.eq(x + y, y + x)
}

test for_all s: String {
  assert.eq(s.len(), s.len())
}
12

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.

imports.lingo
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.

13

Targets

A source file declares the execution substrates it supports. The CLI's --target must be covered by one of the declarations.

targets.lingo
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
CategoryDefault platformsCapabilities
nativelinux, macos, windowsfull stdlib
servernodefull stdlib
webbrowserfull 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.

14

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.

NamespacePurpose
Consolelog / print / message / read (stdout, stderr, stdin)
Listmap, filter, fold, find, sort, min/max, zip, range, join, with_index
Stringsplit, trim, replace, find, slice, chars, parse_int; byte- and codepoint-indexed families
Map / Setinsert, get, has, keys, values, union, intersection, difference
Int / Float / Decimal / BigIntarithmetic, formatting, transcendentals; exact Decimal and arbitrary-precision BigInt
Jsonencode / decode / query (jq-subset paths) / from_map
Fs / Process / Envfilesystem ops, subprocess, args / vars (effectful)
Httpget / post / request (! Net)
Time / Instant / Durationvirtual-clock time, instants, durations with parse / format
Scope / Task / Channel / Streamstructured concurrency, channels, lazy streams
Bytes / Base64 / Compress / Cryptobinary buffers, encoding, gzip, hashing & signing
Regex / Unicode / Char / HtmlRE2 patterns, normalization, character classes, entity escaping
Heap / Arraypriority queue, fixed-size arrays
15

Cheatsheet

collections.lingo
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)
strings.lingo
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(",")
option_result.lingo
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
16

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; var rebinds 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 + Decimal and Int + Float are 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.