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 on the native and WebAssembly targets. On JavaScript an out-of-range Int still drifts to a float instead of failing - the one open leg. 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"hello" is text: one char maps to one byte, so b"deadbeef" is 8 bytes, not 4. Write a single non-printable byte as \xNN. For an opaque blob use the hex literal below.
  • Tagged literals - compile-time-parsed and validated: hex"deadbeef" (Bytes, two hex digits per byte), re`\d+` (Regex), json`{"k": 1}` (JsonValue), lson`Point { x = 1 }`. A b"..." written entirely as \xNN escapes warns T0015 and lingo fix rewrites it to hex"...".
  • 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.

A let tuple destructure must bind every slot, at every nesting level - binding fewer is an error (T0001). Use _ for the slots you do not need: let (first, _, _, _) = wide.

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

Params and the return type are annotated independently, and the rule for the body is: a return type implies braces; param annotations do not. Inferred (x, y) -> x + y (single-param shorthand x -> x + 1); typed params, inferred return (x: Int) -> x * 2; explicit return type, braces required (x: Int) -> Int { x * 2 }; braced body with inferred return (x: Int) -> { let y = x * 2; y + 1 }; with an effect row, which needs a return type (msg: String) -> () ! IO { Console.log(msg) }; and trailing-lambda syntax xs.fold(0) { (acc, x) -> acc + x }. Annotating params is all-or-nothing. The fn(...) keyword is reserved for function types in type position (fn(Int) -> Int as a parameter or return type) and never appears in expression position.

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 }
}

for iterates a List, a Bytes or a Set, binding each element in turn; a Bytes yields one Int per byte, in 0..=255. It is an expression returning () and is legal in value position - let x = for ... { }, an argument, a match arm - on every target. The same holds for while.

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.

Index-assign writes through a path as well as a name: xs[0] = 99, compound xs[1] += 5, and nested grid[0][1] = 7. The receiver must be a var - a let-rooted one is W0001 / W0002 - and a call-bearing index (xs[f()] = v) is rejected P0031, because the compound form re-reads the path and so needs a side-effect-free index.

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.

A consuming method call in statement position rebinds its receiver: xs.push(9) on its own line means xs = xs.push(9), which is why iteration reads like mutation while the values stay immutable. The rule is that the call returns its receiver's own type, so a read like b.xs.len() never rebinds anything, and the same syntax in value position feeds a let instead.

04

Operators

Arithmetic + - * / % is checked - overflow and divide-by-zero are runtime errors, never silent wrapping, on the native and WebAssembly targets, which report byte-identically. On JavaScript an out-of-range Int still drifts to a float instead of failing, and unary - / abs / pow on the minimum Int are wrong there too; that work is in progress. 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.

An arm that yields must say so on the op: a handler arm whose body performs ! Async makes the op hand back a Promise, so the op's effect row is what carries that to every call site. A ctl arm that calls resume may not perform ! Async (T0621), and declaring ! Async on the op does not change that - resume returns non-locally by throwing a sentinel the dispatch catches synchronously, and inside an async arm that throw becomes a promise rejection the catch never sees. Move the awaited work outside the arm. An early-exit ctl arm that never calls resume may be async: declare ! Async on the op and it compiles.

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 and HTTP operations check the cap's restrictions before they run; a violation raises CapabilityDenied. Other namespaces accept caps for threading, but their runtime behaviour is unchanged by the cap value.

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.

Where a stdlib call takes a capability, it goes as a trailing positional argument or as a named cap = ... - Fs.read_file("/tmp/log", cap = fs_cap). Both spellings are enforced identically.

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 and Float 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) returns a (Sender, Receiver) pair - let (tx, rx) = Channel.bounded(8) - so direction lands in the types. tx.send / tx.close (closing is send-end only); rx.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.eq(add(1, 1), 2)
}

The assertion surface is assert.eq / ne / is_true / is_false / is_ok / is_err / contains / matches / fail / raises / raises_with. Lingo is Result-first, so failing paths assert with is_err; assert.raises and assert.raises_with are the secondary path for code that panics rather than returning Err. To fail a branch outright, call assert.fail(msg) - not assert.is_true(false), which reports only "expected true" and discards your message.

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

A file is a module: its Capitalized filename stem is the module name (Numbers.lingo is Numbers), and a lingo.lson project manifest is what activates local imports. Top-level fn, type, let, class, and alias are module-private by default; pub exports them. Values are reached qualified (Numbers.double(n)) while an import's pub types, constructors, and classes merge in bare. Imports do not re-export - a module that imports a name does not pass it on transitively.

Imports.lingo
// The stdlib is ambient - Console, List, Http resolve with no import.
// `import` is only for a local project module or a remote package:
import Numbers                                       // local module: Numbers.double(n)
import Shapes                                        // its pub types / ctors merge bare: Point, Circle
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.

A published package declares the capabilities it wants from its consumer as a top-level requires binding in its own lingo.lson, alongside project / package / ffi. lingo add discloses that requirement to the installing author and records it in the lockfile, and a later version that widens what it asks for raises a warning rather than escalating silently.

13

Targets

Native is the default substrate: a file with no target line runs natively with no declaration at all (a sole bare target native is rejected as redundant). Declaring target is an opt-in portability promise - narrow native to specific platforms, or add a non-native substrate. The CLI's --target must be covered by one of the declarations.

Targets.lingo
target native [linux, macos]   // narrow native to specific platforms
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, Http, Serve (and Crypto, deferred pending async Web Crypto). Calling an excluded namespace is a compile-time T0050.
contractcosmwasma deliberately narrow admit set: the auto-prelude sums and collections (Option, Result, List), in-wasm Json, and Cosmwasm. A contract has no JS host, so there is no Console (no stdout) and no host crypto, net or fs.

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 declare targets exactly once, in the targets field of the lingo.lson manifest - a leftover per-file target line inside a project is rejected.

14

Standard library

The core namespaces are ambient - Console.log(...), Json.encode(...), and the collection methods work with no import and no declaration. 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, index_of, slice, chars, parse_int; uniformly codepoint-indexed, with char_indices / byte_len for explicit byte offsets
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-flavour patterns (linear-time, ReDoS-safe: the Rust regex crate natively, vendored re2js on JS), 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(",")
OptionResult.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 the complete programs on this page.