Learn Lumen

A tour of the language in fourteen short stops. If you write TypeScript you already know the syntax; what changes is that every program is checked and compiled ahead of time to a native binary or a single WebAssembly module. Each sample was checked with the released compiler, and the ones marked Open in playground run in your browser.

Types you already know

Lumen is TypeScript syntax with the types checked ahead of time and nothing dynamic left over. int (also spelled i32) is a 32-bit integer and number a 64-bit float; i64, bool and string round out the scalars. Locals infer their type from the initializer, and so do function return types, so annotations go where they help a reader: parameters and public signatures.

Mixing int and number promotes to number, and int widens losslessly to i64, the way TypeScript's single number would let you expect.

// `int` is a 32-bit integer, `number` a 64-bit float. Locals and
// return types are inferred; annotate where it helps the reader.
const name: string = "Lumen";
let year = 2026;            // int
let ratio = 0.75;           // number
let ready: bool = true;

function describe(n: int) {
  return `${name} ${n}`;    // inferred: string
}

console.log(describe(year));   // Lumen 2026
console.log(ratio * 2);        // 1.5
console.log(ready);            // true

Records, unions and enums

Object shapes are closed: an interface or record type says exactly which fields exist. A wider record still flows into a narrower parameter (structural width subtyping). Unions are built from named types, and a shared literal field such as kind makes them discriminated, so switch narrows each branch and the compiler checks that every variant is handled. Tuples are fixed-shape arrays like [int, string]; enums are plain integer members.

interface Point {
  x: int;
  y: int;
}

type Circle = { kind: "circle"; radius: int };
type Square = { kind: "square"; side: int };
type Shape = Circle | Square;   // discriminated union

enum Level { Low, High }

function area(s: Shape): int {
  switch (s.kind) {
    case "circle": return s.radius * s.radius * 3;
    case "square": return s.side * s.side;
  }
}

let p: Point = { x: 3, y: 4 };
let pair: [int, string] = [1, "one"];

console.log(p.x + p.y);                            // 7
console.log(area({ kind: "circle", radius: 2 }));  // 12
console.log(pair[1]);                              // one
console.log(Level.High);                           // 1

Control flow

Everything you would write in TypeScript: if/else, while, do, C-style for, for…of over arrays, strings, Set and Map, for…in, the ternary, labeled break/continue, and every compound assignment (+=, ??=, <<=, **=, …). A switch over an enum or a literal union is exhaustive: a missing case is a compile error, and a function that returns from every case needs no fallback return.

enum Color { Red, Green, Blue }

function name(c: Color): string {
  switch (c) {              // exhaustive: every enum member handled
    case Color.Red: return "red";
    case Color.Green: return "green";
    case Color.Blue: return "blue";
  }
}

let total = 0;
for (let i = 1; i <= 10; i += 1) {
  if (i % 2 == 0) continue;
  total += i;
}
console.log(total);                     // 25

for (const c of [Color.Red, Color.Blue]) {
  console.log(name(c));                 // red, blue
}

let n = 27;
let steps = 0;
while (n != 1) {
  n = n % 2 == 0 ? n / 2 : 3 * n + 1;
  steps += 1;
}
console.log(steps);                     // 111

outer: for (let a = 0; a < 3; a += 1) {
  for (let b = 0; b < 3; b += 1) {
    if (a * b == 2) { console.log(`${a}*${b}`); break outer; }
  }
}

Null safety

There is no undefined. A value that may be absent is T | null, an optional field port? is the same thing, and ??, ?. (including ?.[i], ?.() and ?.length) and the non-null assertion x! work on it. The compiler narrows across if/else, guard clauses, while, &&/||, and field paths like a.b.c != null, so after the check you use the value as a plain T.

interface Settings {
  host: string;
  port?: int;               // optional field: int | null
}

function address(s: Settings): string {
  let port = s.port ?? 8080;
  return `${s.host}:${port}`;
}

function shout(word: string | null): string {
  if (word == null) return "...";   // narrowed to string below
  return word.toUpperCase();
}

let maybe: string | null = null;
console.log(maybe?.length ?? 0);                     // 0
console.log(address({ host: "localhost" }));         // localhost:8080
console.log(address({ host: "api", port: 443 }));    // api:443
console.log(shout("hey"));                           // HEY
console.log(shout(null));                            // ...

Functions and closures

Default and rest parameters, spread at the call site, arrow functions, function types, and closures that capture their surroundings. Everything is checked statically, so there is no arguments object. Array and object destructuring work in declarations, parameters and assignment, with defaults ([a = 1], { x = 1 }).

One rule to know: a closure can read a captured variable but not assign to it. The compiler reports E_CAPTURED_MUTATION and points you at for…of or reduce instead, which keeps closures cheap to compile to native code.

function greet(name: string, greeting: string = "Hello"): string {
  return `${greeting}, ${name}`;
}

function sum(...nums: int[]): int {
  let total = 0;
  for (const n of nums) total += n;
  return total;
}

function makeAdder(n: int): (x: int) => int {
  return (x: int) => x + n;       // a closure captures n
}

const applyTwice = (f: (n: int) => int, v: int): int => f(f(v));

let extra: int[] = [4, 5, 6];
let add10 = makeAdder(10);
let first = 10;
let second = 20;
[first, second] = [second, first];   // destructuring assignment

console.log(greet("World"));               // Hello, World
console.log(sum(1, 2, 3, ...extra));       // 21
console.log(add10(5));                     // 15
console.log(applyTwice((x: int) => x * 3, 2));   // 18
console.log(first);                        // 20

Classes and inheritance

class, extends, super, public/private/protected, #private fields, static, readonly, and get/set accessors. A field with an initializer infers its type (count = 0 is an int). Methods take default, optional and rest parameters like any function, and method calls compile to direct calls on a flat struct: there is no prototype chain to walk.

class Animal {
  static created = 0;
  protected name: string;
  readonly legs: int;

  constructor(name: string, legs: int) {
    this.name = name;
    this.legs = legs;
    Animal.created += 1;
  }

  get label(): string { return `${this.name} (${this.legs} legs)`; }
  speak(): string { return `${this.name} makes a sound`; }
}

class Dog extends Animal {
  constructor(name: string) { super(name, 4); }
  speak(): string { return super.speak() + " (woof)"; }
}

let rex = new Dog("Rex");
console.log(rex.speak());        // Rex makes a sound (woof)
console.log(rex.label);          // Rex (4 legs)
console.log(Animal.created);     // 1

Generics

Generic functions, classes and interfaces, with constraints (<T extends …>) and defaults (<T = …>). Each instantiation is monomorphized at compile time, so Box<int> holds a bare integer with no boxing. A method cannot yet introduce a type parameter of its own; make the class generic or use a generic free function instead.

function firstOf<T>(xs: T[]): T {
  return xs[0];
}

class Box<T> {
  value: T;
  constructor(v: T) { this.value = v; }
  get(): T { return this.value; }
}

function largest<T extends int>(a: T, b: T): T {
  return a > b ? a : b;
}

let boxed = new Box<int>(21);

console.log(firstOf<int>([10, 20, 30]));   // 10
console.log(boxed.get() * 2);              // 42
console.log(largest(3, 9));                // 9

Strings and regex

Single- and double-quoted literals, template literals with string and numeric interpolation, and the methods you reach for: split, slice, trim, includes, indexOf, replace, repeat, padStart, toUpperCase and more. Regex literals with .test() match JavaScript's results exactly; anchored patterns compile to specialized native matchers at build time, and everything else falls back to a built-in engine.

let csv = "  ada, grace ,linus ";
let names = csv.trim().split(",");

for (const n of names) {
  console.log(n.trim().toUpperCase().padStart(6, "."));   // ...ADA / .GRACE / .LINUS
}

console.log("hello".repeat(2).replace("lo", "p"));   // helphello
console.log("grace,hopper".includes("grace"));      // true

// Regex literals compile to native matchers at build time.
const semver = /^\d+\.\d+\.\d+$/;
console.log(semver.test("1.2.30"));   // true
console.log(semver.test("1.2"));      // false

Arrays, Map and Set

Arrays carry map, filter, reduce, forEach, find, some, every, includes, indexOf and join. Map<K, V> and Set<T> are typed and iterable with for…of. JSON.parse<T> takes the shape you expect and returns exactly that: there is no untyped JSON value to inspect at runtime.

Arrays are fixed-length today. push, pop and sort are on the roadmap; until then build a new array with spread, map or filter.

let nums: int[] = [1, 2, 3, 4, 5, 6];
let evens = nums.filter((n: int) => n % 2 == 0);
let total = nums.reduce((acc: int, n: int) => acc + n, 0);

console.log(evens.map((n: int) => n * n).join(", "));   // 4, 16, 36
console.log(total);                                     // 21
console.log(nums.some((n: int) => n > 5));              // true

let scores: Map<string, int> = new Map<string, int>();
scores.set("ada", 90);
scores.set("linus", 85);
for (const [who, score] of scores) {
  console.log(`${who}: ${score}`);
}

let tags = new Set<string>();
tags.add("red");
tags.add("red");
console.log(tags.size);                                 // 1

Errors, defer and tests

throw and try/catch/finally propagate across functions, methods and constructors, with a readable .message and a real stack trace on an uncaught error. using _ = defer(…) runs cleanup when the enclosing scope exits, last in first out. Tests live next to the code as test "…" { expect(…); } blocks and run with lumen test file.ts; they are ignored by lumen compile.

function parseAge(s: string): int {
  let n = parseInt(s);
  if (n == null || n < 0) throw new Error(`bad age: ${s}`);
  return n;
}

function withFile(): void {
  using _ = defer(() => console.log("closed"));   // runs at scope exit, LIFO
  console.log("open");
}

try {
  console.log(parseAge("42"));
  console.log(parseAge("-1"));
} catch (e) {
  console.log(e.message);       // bad age: -1
} finally {
  withFile();                   // open, closed
}

test "parseAge accepts digits" {
  expect(parseAge("7") == 7);
}

Async, threads and events

async functions return Promise<T> and await resolves them on a native event loop, alongside setTimeout/setInterval. Worker.run(fn) starts a real OS thread and hands back a promise for its result, so CPU-bound work runs in parallel. EventEmitter<T> carries one payload type per instance. These need the native target: the in-browser wasm build has no event loop yet.

async function fetchBase(): Promise<int> {
  return 10;
}

async function total(n: int): Promise<int> {
  let base = await fetchBase();
  return base + n;
}

function fib(): i32 {
  let a: i32 = 0;
  let b: i32 = 1;
  for (let i = 0; i < 40; i += 1) { const next = a + b; a = b; b = next; }
  return a;
}

const emitter = new EventEmitter<string>();
emitter.on("greet", (msg: string) => console.log(msg));
emitter.emit("greet", "hello");          // hello

console.log(await total(32));            // 42
const p = Worker.run(fib);               // a real OS thread
console.log("main keeps going");
console.log(await p);                    // 102334155

Calling C

Name a library with // @link, declare the functions you use, and call them. Scalars and strings cross the boundary; the compiler links the library into the binary. Packages such as sqlite and quickjs are built this way, and on the wasm target they embed the C library into the module with a // @wasm-link archive. Details are in the FFI reference.

// Name the library once; declare the C functions you use.
// @link m
declare function pow(base: number, exp: number): number;
declare function sqrt(x: number): number;

console.log(sqrt(pow(3.0, 2.0) + pow(4.0, 2.0)));   // 5

Modules and URL imports

Default, named and import * as ns imports, plus export … from re-exports, from a relative file or from an https:// URL. A package is just a URL: the source is fetched at compile time and compiled in, so there is no package manager, install step or lockfile. The catalog lists what is available.

// A package is a URL: fetched over HTTPS and compiled in. No package
// manager, no lockfile. Relative imports work the same way.
import greet from "https://lumen-lang.org/package/std-contrib/hello/hello.ts";
import { render } from "https://lumen-lang.org/package/std-contrib/markdown/markdown.ts";

console.log(greet("world"));          // Hello, world!
console.log(render("# Hi\n\n*md*"));

Build, run and test

One binary, six commands. lumen init writes a starter project with a tsconfig.json so your editor's TypeScript tooling works out of the box.

lumen init my-app && cd my-app
lumen check main.ts                 # type-check only
lumen run main.ts                   # build and run
lumen compile main.ts               # native binary next to the source
lumen compile --release-fast main.ts
lumen compile --wasm main.ts        # one self-contained .wasm (WASI)
lumen watch main.ts                 # rebuild and re-run on every save
lumen test main.ts                  # run the test blocks

Cross-compile with --target <triple>, ask for a self-contained binary with --static, and link a vendored C library with --link / --library-path. The install section walks through each.

What is deliberately left out

Lumen removes the parts of JavaScript that need a runtime to interpret them: no prototypes, no eval, no dynamic object shapes, no arguments, no CommonJS, no any escape hatch. What remains compiles to a small, dependency-free executable that starts in milliseconds. The roadmap lists what is still being added.

Reference summary

The whole surface on one screen. The standard library reference covers the modules; this is the language itself.

Static types
Local inference and return-type inference for functions, arrows, and methods, int/i64/number/bool/string, float & hex/binary literals. Numeric promotion (int + number) and lossless int → i64 widening, just like TypeScript's single number.
Records, interfaces & unions
Closed object shapes, nested records, arrays of records, enums, and fixed‑shape tuples like [int, string]. Structural width subtyping: a wider record flows into a narrower one, so interface parameters accept any matching shape. Plus type aliases, discriminated and string‑literal unions, as assertions, as const, satisfies, and readonly arrays.
Control flow & operators
if/else if, while, do, C‑style for, for…of (arrays, strings, Set, Map), for…in, switch (exhaustive over literal unions and enums), ternary, and labeled break/continue. The full arithmetic, bitwise, and shift set, with every compound‑assignment form (+=, &&=, ??=, <<=, **=, and more).
Null safety & narrowing
T | null, optional ? fields, ??, optional chaining ?./?.[i]/?.()/?.length, and the non-null assertion x!. Flow narrowing across if/else, guard clauses (if (x == null) return), while, &&/||, and deep field paths (a.b.c != null) — plus discriminated-union complement narrowing.
Functions & parameters
Function types, arrow functions, and capturing closures. Default values, rest parameters, and spread, checked statically, no arguments object. Object shorthand { x } and computed keys, destructuring defaults in array ([a = 1]) and object ({ x = 1 }) patterns, and destructuring assignment ([a, b] = [b, a]).
Classes & inheritance
extends, super, public/private/protected, #private fields, static, readonly, and get/set. Fields infer their type from an initializer (count = 0); methods take default, optional, and rest parameters.
Generics
Generic functions, classes, and interfaces with Array<T>, monomorphized at compile time, no boxing. Type-parameter constraints (<T extends ...>) and defaults (<T = ...>).
Strings & regex
Single‑ and double‑quoted literals, template literals `hi ${name}` with string & numeric interpolation, and the methods you expect (split, slice, trim, includes, indexOf, replace, repeat, padStart, toUpperCase, and more). /pattern/ literals with .test(): anchored patterns compile to specialized native matchers at build time, ~3× faster than V8 on checks like semver and identifiers, and everything else falls back to a built-in engine, so every pattern works.
Collections & helpers
Map<K, V> and Set<T>, both iterable with for…of, and the array methods you expect (map, filter, reduce, forEach, find, some, every, includes, indexOf, join). Plus Date.now(), Object.keys/Object.freeze, Array.isArray/Array.from/Array.of, JSON.stringify/parse<T>, parseInt/parseFloat, and the full Math/Number surface.
Errors, defer & tests
try/catch/finally and throw, propagating across functions, methods, and constructors; readable .message, optional catch binding, and full stack traces on uncaught errors. Scope‑exit cleanup with defer (last‑in‑first‑out), and built‑in tests: test "…" { expect(…); } run with lumen test.
Async, threads & events
async functions return Promise<T> and await resolves them on a fast native event loop, with setTimeout/setInterval and their clear* counterparts. Worker.run(fn) spawns a real, detached OS thread — genuine CPU parallelism, with no per‑thread interpreter overhead to pay. EventEmitter<T> is statically typed, one payload type per instance, and ~3.5× faster than Node's on a tight emit loop.
Buffer, crypto & I/O
A real byte‑array type, distinct from string at compile time: Buffer.from/.alloc, utf8/hex/base64 encodings, .slice/.at/.equals. crypto.hmacSync and AES‑256‑GCM encryptSync/decryptSync both read and return it. Alongside net TCP sockets, an http client & concurrent server, process.stdin/stdout/stderr as streams, readline.question, and zlib gzip/deflate.
Modules & native output
Default, named, and import * as ns imports, and export … from re-exports, from a relative file or an https:// URL — a package is just a URL. declare function + // @link calls native C libraries, with scalars and strings marshalling across the boundary. It all compiles to a small, dependency‑free native binary.