Pico
Pico

The syntax you know.
Native speed.

Pico is a statically typed, garbage-collected language with TypeScript-based syntax. It compiles ahead of time through LLVM to native code on Linux, macOS and Windows, and to WebAssembly for the browser and WASI.

hello.pi
import { readText } from "std/fs"

const text = await readText("notes.txt")
let count = 0
for (const line of text.split("\n")) {
  if (line.startsWith("#")) count++
}
println(`${count} headings`)
$ pico run hello.pi
12 headings

Get started

One download, nothing else to install: the compiler, clang, lld, the runtime and the standard library are inside pico.

macOS Apple silicon

curl -L https://github.com/padcom/pico-lang/releases/download/v0.4.1/pico-0.4.1-macos-arm64.tar.gz | tar xz
export PATH="$PWD/pico-0.4.1-macos-arm64/bin:$PATH"

Programs for macOS itself also need the Xcode command line tools (xcode-select --install).

Linux x86_64 · arm64

curl -L https://github.com/padcom/pico-lang/releases/download/v0.4.1/pico-0.4.1-linux-x64.tar.gz | tar xz
export PATH="$PWD/pico-0.4.1-linux-x64/bin:$PATH"

For arm64, linux-arm64. Needs glibc 2.39 or later (Ubuntu 24.04, Debian 13, Fedora 40).

Windows x86_64 · arm64

curl.exe -LO https://github.com/padcom/pico-lang/releases/download/v0.4.1/pico-0.4.1-windows-x64.zip
tar -xf pico-0.4.1-windows-x64.zip
$env:PATH = "$PWD\pico-0.4.1-windows-x64\bin;$env:PATH"

In PowerShell, on Windows 10 or later. For arm64, windows-arm64.

Then play with reactivity

Save this as counter.pi and run pico run counter.pi. Change it and run it again: pico run --interp skips the native build. For VS Code, install the Pico extension from the Marketplace (search for “Pico” in the Extensions view, or code --install-extension padcom.pico-lang): highlighting, diagnostics, completion and debugging, through the pico on your PATH. Every release is on the releases page, and the language is described in the tour and the spec.

class Todo {
  constructor(public title: string, public done: bool) {}
}

reactive todos: Todo[] = []
const left = computed(() => todos.filter(t => !t.done).length)

watchEffect(() => println(`${left} of ${todos.length} left`))

todos.push(new Todo("download pico", true))
todos.push(new Todo("try reactivity", false))
todos[1].done = true

The basics

Familiar where it can be, stricter where it matters.

TypeScript look and feel

Classes, interfaces, generics, arrow functions, template literals and async/await, written the way you already write them.

Sound, nominal types

No any, no undefined, and a single null that has to be declared. Types are compared by name, not by shape.

Built for speed

Sized numbers (i8 to u64, f32, f64), struct value types, monomorphized generics and an LLVM backend.

Everything is an expression

if, match, blocks and loops produce values. A function's last expression is its result.

Native and the web

One codebase for x86_64 and arm64 executables, WASI modules and the browser, with C and JavaScript interop.

One tool

pico builds, runs, tests, formats and lints, manages packages, and runs the REPL, the debugger and the language server.

A tour in twenty features

Scroll down: the code on the right follows along.

    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")

    One tool for the whole workflow

    No separate build system, formatter, test runner or package manager to set up.

    pico run script.pi            # compile and run
    pico build --release          # native executable
    pico build --target wasm32-wasi
    pico test --watch             # every *.test.pi
    pico fmt --check
    pico lint
    pico doc
    pico explain E0308            # long-form error help
    • Language server for diagnostics, formatting and navigation in VS Code.
    • Debugger through pico dap and lldb.
    • Packages come straight from git, pinned by project.json.
    • Targets: Linux, macOS and Windows on x86_64 and arm64, WASI and the browser.