01
Variables
let declares a binding you can reassign. Types are inferred, or written after a colon. Nullable types are opt-in with ?, and the compiler narrows them after a check.
let count = 0 // inferred: int (i64)
let ratio: f32 = 0.5
let name: string = "Ann"
count += 1
name = `${name} Smith`
let nick: string? = null // string? is string | null
println(nick?.length ?? 0) // 0
if (nick != null) {
println(nick.toUpperCase()) // narrowed to string
}
02
Constants
const bindings can't be reassigned. Constant expressions are folded by the compiler, and overflow is caught at compile time. For deep read-only access, use readonly fields and immutable views.
const MAX_USERS = 1_000
const KB = 1 << 10 // folded at compile time
const BAD = 255u8 + 1u8 // error: overflow in a constant
const list = [1, 2]
list = [] // error: const binding
list.push(3) // ok: only the binding is const
function render(scene: immutable Scene) {
println(scene.count()) // ok: a readonly method
scene.nodes.push(node) // error: mutation through immutable
}
03
Lists
Arrays grow, are bounds-checked and come with the methods you expect. Destructuring and for…of work as usual. Fixed-size arrays and tuples are value types stored inline.
const xs: i32[] = [5, 3, 8]
xs.push(1)
xs.sort()
println(xs) // [1, 3, 5, 8]
println(xs.map(n => n * 2).filter(n => n > 4)) // [6, 10, 16]
const [head, ...tail] = xs
for (const [i, x] of xs.enumerate()) println(`${i}: ${x}`)
const grid: [u8; 16] = [0; 16] // fixed-size, inline
const pair: [string, i32] = ["age", 42] // tuple
xs[10] // panic: index out of bounds
04
Maps
Map and Set are generic built-ins. An object literal typed as a Map becomes a map literal, and iterating gives you key/value tuples.
const ages = new Map<string, i32>()
ages.set("Ann", 31)
ages.set("Bob", 27)
const env: Map<string, string> = { // a map literal
"LANG": "C",
HOME: "/home/ann",
}
for (const [name, age] of ages) println(`${name}: ${age}`)
for (const key of env.keys()) println(key)
const tags = new Set<string>(["new", "sale"])
05
Ranges
a..b is an exclusive, lazy Range of integers. Loop over it, step through it, chain iterator adapters on it, or use it as a pattern in match. Stepping never overflows the counter.
for (const i of 0..5) print(`${i} `) // 0 1 2 3 4
const r = 3..7
println(`${r.start} ${r.end}`) // 3 7
for (const i of (0..100).step(25)) print(`${i} `) // 0 25 50 75
let last: u8 = 0
for (const i of (0u8..255).step(10)) last = i // 250, no overflow
const evens = (0..10).filter(n => n % 2 == 0).map(n => n * n) // lazy
const kind = match (status) {
200..300 => "ok", // range pattern
300..400 => "redirect",
_ => "error",
}
06
Functions
Parameters have types; the last expression is the result. Parameters can have defaults (which may use earlier ones) or be optional with ?, any argument can be passed by name, and overloads are resolved at compile time.
function add(a: int, b: int): int { a + b } // the last expression is the result
function greet(name: string, greeting = "Hello", suffix?: string): string {
`${greeting}, ${name}${suffix ?? ""}`
}
println(greet("Ann")) // Hello, Ann
println(greet("Bob", suffix: "!")) // named arguments: Hello, Bob!
println(greet(greeting: "Hi", name: "Cy"))
function area(w: f64, h: f64 = w): f64 { w * h } // defaults can use earlier parameters
println(area(3)) // 9
function len(s: string): int { s.length } // overloads, picked statically
function len<T>(a: T[]): int { a.length }
println(`${len("pico")} ${len([1, 2, 3])}`)
07
Rest and spread
A rest parameter ...xs collects the remaining arguments into an array. ... spreads an array into a call or an array literal, a tuple into fixed parameters, and in a destructuring pattern collects the rest.
function sum(...xs: int[]): int { xs.reduce((a, b) => a + b, 0) }
println(sum(1, 2, 3)) // 6
const numbers = [4, 5, 6]
println(sum(...numbers)) // 15: an array spread into the rest
function move(x: int, y: int): string { `${x},${y}` }
const pos: [int, int] = [3, 4]
println(move(...pos)) // a tuple spread into parameters
const more = [0, ...numbers, 7] // and into array literals
const [first, ...rest] = more // ...rest collects the remainder
println(`${first} ${rest:?}`)
08
using
A using binding is disposed automatically when its scope ends, in reverse order, including when an exception leaves the scope. await using does the same for async resources such as files and sockets.
import { open } from "std/fs"
async function copy(source: string, target: string) {
await using src = await open(source)
await using dst = await open(target, write: true, create: true)
loop {
const chunk = await src.read(64 * 1024) ?? break
await dst.write(chunk)
}
} // closes dst, then src, even on an exception
class Timer implements Disposable {
private start = performance.now()
constructor(private label: string) {}
public dispose() {
println(`${label}: ${performance.now() - start:.1f} ms`)
}
}
function build() {
using t = new Timer("build")
compileAll()
} // prints "build: 812.4 ms" on the way out
09
Reactivity
A reactive binding knows who reads it. computed values are lazy and cached; watchEffect runs again, synchronously, whenever what it read changes. It's deep: mutating an array or an object held in reactive state counts too, through any reference.
reactive price = 10
reactive todos: string[] = []
const total = computed(() => price * todos.length)
watchEffect(() => println(`total: ${total}`)) // total: 0
todos.push("ship it") // total: 10
price = 12 // total: 12
10
Classes
Parameter properties, getters, setters and overloaded constructors. Methods are final unless they're marked virtual, and overriding requires override. Inside a class, this. is optional.
class Animal {
constructor(public name: string, protected age: i32) {}
public get description(): string { `${name} (${age})` }
public virtual speak(): string { "..." }
public describe(): string { `${name} says ${speak()}` }
}
class Dog extends Animal {
constructor(name: string) { super(name, 3) }
public override speak(): string { "Woof" }
}
const d: Animal = new Dog("Rex")
println(d.describe()) // Rex says Woof
println(d.description) // Rex (3)
11
Interfaces
Interfaces are nominal: a class implements one only when it says so. They can carry default methods, extend each other, and be implemented by structs and enums too.
interface Shape {
area(): f64
describe(): string { `area ${area():.2f}` } // default method
}
class Circle implements Shape {
constructor(public r: f64) {}
public area(): f64 { Math.PI * r * r }
}
class Blob { // the same shape, but no `implements`
public area(): f64 { 1 }
}
const c: Shape = new Circle(2)
println(c.describe()) // area 12.57
const b: Shape = new Blob() // error: interfaces are nominal
12
Lambdas
Arrow functions are first-class values with typed signatures. They capture let bindings by reference, const bindings by value, and this lexically.
type Predicate<T> = (x: T) => bool
function keep<T>(xs: T[], test: Predicate<T>): T[] { xs.filter(test) }
const evens = keep([1, 2, 3, 4], n => n % 2 == 0) // [2, 4]
function makeCounter(): () => int {
let n = 0
() => ++n // the last expression is the result
}
const next = makeCounter()
next(); next()
println(next()) // 3
const sum = [1, 2, 3].reduce((a, b) => a + b, 0)
13
Generators
function* and yield produce lazy iterators. Ranges and iterator adapters are lazy too, and any class becomes iterable by implementing Iterable<T>. Async generators work with for await.
function* fib(): Iterator<i64> {
let [a, b] = [0, 1]
loop { yield a; [a, b] = [b, a + b] }
}
for (const n of fib().take(10)) println(n) // 0 1 1 2 3 5 8 13 21 34
const squares = (0..100).filter(n => n % 2 == 0).map(n => n * n)
class Countdown implements Iterable<i32> {
constructor(private from: i32) {}
public *iterator(): Iterator<i32> {
for (let i = from; i > 0; i--) yield i
}
}
for (const n of new Countdown(3)) println(n) // 3 2 1
async function* lines(path: string): AsyncIterator<string> { … }
for await (const line of lines("log.txt")) println(line)
14
The match operator
match is an expression. It destructures enums, structs, arrays and tuples, and supports guards, or-patterns, ranges and type tests. A match that misses a case is a compile error.
enum Shape {
Circle(r: f64),
Rect(w: f64, h: f64),
Empty,
}
const desc = match (shape) {
Circle { r } if r > 10 => "big circle",
Circle { r } => `circle ${r}`,
Rect(w, h) if w == h => "square",
Rect { w, h } => `rect ${w}x${h}`,
Empty => "nothing",
}
const kind = match (status) {
200 | 201 | 204 => "ok",
300..400 => "redirect",
_ => "other",
}
15
Exceptions
Exceptions are checked: a function lists what it can throw, and the compiler makes callers handle or declare it. try is an expression, a catch clause can destructure the error, and try? turns an error into null.
class IoError extends Error {
constructor(public path: string, msg: string) { super(msg) }
}
function readConfig(path: string): Config throws IoError | ParseError {
parseConfig(readText(path))
}
const config = try {
readConfig("app.json")
} catch (e: IoError { path }) { // destructures the error
println(`cannot read ${path}`)
Config.default()
} catch (e: ParseError) {
throw new ConfigError("bad config", cause: e)
} finally {
log.flush()
}
const port = try parsePort(s) catch (e: ParseError) 8080
const maybe = try? parsePort(s) // u16?: an error becomes null
readConfig("x.json") // error: errors not handled
16
Modules
Every file is a module and everything in it is private until it's exported. Imports cover local files, the standard library, git dependencies from project.json, C headers and, in the browser, JavaScript.
// geometry.pi
export struct Vec2 { x: f32; y: f32 }
export function dot(a: Vec2, b: Vec2): f32 { a.x * b.x + a.y * b.y }
export const ORIGIN = Vec2 { x: 0, y: 0 }
function helper() { … } // module-private
// main.pi
import { Vec2, dot } from "./geometry" // .pi or /index.pi
import * as geo from "./geometry"
import { dot as dotProduct } from "./geometry"
import { readText } from "std/fs" // standard library
import { Router } from "padcom/router" // from project.json
import * as m from "c:math.h" // a C header
import { document } from "js:dom" // browser target only
export { Vec2 } from "./geometry" // re-export
println(dot(Vec2 { x: 1, y: 2 }, geo.ORIGIN))
17
Workers
Workers run in parallel, each on its own thread with its own heap. Messages must be Sendable and are copied, or moved when you transfer them. An inline worker takes a capture-free function, and parallelMap spreads work over a pool.
// main.pi
import { Job, Result } from "./types"
const w = new Worker<Job, Result>("./job")
w.onmessage = (r: Result) => println(`done: ${r.value}`)
w.onerror = (e: WorkerPanicked) => println(`worker died: ${e.message}`)
w.postMessage(Job { id: 1, data: [1, 2, 3] }) // copied, not shared
// job.pi
import { self } from "std/worker"
import { Job, Result } from "./types"
self.onmessage = (job: Job) => {
self.postMessage(Result { value: job.data.sum() })
}
// Inline workers: no separate file, no captured variables
const fibWorker = new Worker((n: i64): i64 => fib(n))
const r = await fibWorker.call(40)
import { parallelMap } from "std/worker"
const hashes = await parallelMap(files, (f: string) => sha256File(f),
workers: 8)
18
Comptime
Ordinary Pico code can run inside the compiler. Use it to build lookup tables, read files at build time, take compile-time parameters, or pick an implementation per target.
function buildCrcTable(): [u32; 256] {
const t: [u32; 256] = [0; 256]
for (const i of 0..256) {
let c = i as u32
for (const _ of 0..8) {
c = if ((c & 1) != 0) 0xEDB88320 ^ (c >>> 1) else c >>> 1
}
t[i] = c
}
t
}
comptime const CRC_TABLE = buildCrcTable() // runs in the compiler
function zeros<comptime N: i32>(): [f32; N] { [0; N] }
const v = zeros<4>()
const VERSION = comptime comptime.readFile("VERSION").trim()
const impl = comptime if (target.arch == "wasm32") wasmImpl
else nativeImpl
19
Decorators
Decorators are comptime functions that inspect and rewrite declarations. They can add members, wrap bodies with type-checked templates, and report errors. The standard library brings @derive.
import { FunctionDecl } from "std/meta"
export decorator function memoize(fn: FunctionDecl): FunctionDecl {
const cache = fn.addStatic("cache",
`new Map<u64, ${fn.returnType}>()`) // a hidden static
fn.wrapBody((args, call) => `
const key = ${args.hashKey()}
const hit = ${cache}.get(key)
if (hit != null) return hit
const v = ${call}
${cache}.set(key, v)
v
`)
fn
}
@memoize
function fib(n: i64): i64 { if (n < 2) n else fib(n - 1) + fib(n - 2) }
@derive(Eq, Hash)
class User { constructor(public id: i64, public name: string) {} }
20
Reflection
std/reflect describes types at run time: fields, methods, interfaces and decorators, with typed getters and setters. Metadata is only emitted for types that can reach a reflection call, so programs that don't use it pay nothing.
import { reflect } from "std/reflect"
@derive(Eq, Hash)
class User {
constructor(public id: i64, public name: string, public age: int) {}
public greet(): string { `Hi, ${name}` }
}
const user = new User(1, "Ann", 31)
const t = reflect.typeOf(user)
println(t.name) // User
for (const f of t.fields) {
println(`${f.name}: ${f.type.name} = ${f.show(user)}`)
}
const age = t.field("age")!.get<int>(user) // typed access
println(t.methods.map(m => m.name)) // ["greet"]
println(t.decorators.map(d => d.name)) // ["derive"]
reflect.typeOf<User>().field("name")!.set<string>(user, "Annie")