▄▀█ █░░
   █▀█ █▄▄

   Usage:
     al run <file.al>      Run a program
     al repl               Start interactive REPL
     al check <file.al>    Type-check without running
     al fmt [path]         Format source files
     al --help             Show all commands

   Example:
     al run hello.al
     al repl
Install
curl -fsSL al.alistair.sh/install.sh | bash

How it works

AL is statically typed with Hindley-Milner type inference. Every expression has a type known at compile time. The checker catches errors before your code runs, while inference keeps the syntax clean—no annotations needed for variables or function parameters. Types and constructors are capitalized (Int, Some); variables, functions, and field labels are lowercase.

The compiler is written in Rust, producing a single native binary with no dependencies. It compiles to bytecode and runs on a stack-based virtual machine. Includes an interactive REPL for exploration and a code formatter for consistent style.

AL has a single type keyword for all data — records, sums, generics, and aliases — with constructors as ordinary functions. Option and Result are regular prelude types; both are unwrapped with the same or syntax.

Quick start

Create a file called hello.al:

type Person {
    name String,
    age Int,
}

fn greet(p Person) String {
    'Hello, ${p.name}! You are ${p.age} years old.'
}

person = Person(name: 'Alice', age: 30)
println(greet(person))

Run with al run hello.al

Language reference

Variables and types

Variables are declared with assignment. Types are inferred from values. Reassignment shadows the previous binding.

// Types inferred from values
count = 42           // Int
name = 'alice'       // String
_active = True       // Bool

// Reassignment shadows the previous binding
x = 10
x = x + 1  // x is now 11

// Arrays — indexing returns Option(t)
numbers = [1, 2, 3, 4, 5]
first = numbers[0] or 0

println('${name}: ${count}')  // alice: 42
println(x)                    // 11
println(first)                // 1

Constants

Top-level constants are declared with 'const'. They cannot be reassigned.

const pi = 314
const app_name = 'my app'
const max_retries = 3

greeting = 'Welcome to ${app_name}!'
println(greeting)  // Welcome to my app!

Basic operators

Standard arithmetic, comparison, and logical operators.

a = 5
b = 3

// Arithmetic
sum = a + b
diff = a - b
prod = a * b
quot = a / b
rem = a % b

// Comparison
eq = a == b
neq = a != b
lt = a < b
lte = a <= b

// Logical
and_result = True && False
or_result = True || False
not_result = !True

println('${sum} ${diff} ${prod} ${quot} ${rem}')     // 8 2 15 1 2
println('${eq} ${neq} ${lt} ${lte}')                 // False True False False
println('${and_result} ${or_result} ${not_result}')   // False True False

Functions with type inference

Function parameter and return types are inferred from usage. Named function bodies are always blocks. Add explicit types when needed.

// Types fully inferred
fn double(x) { x * 2 }
fn add(a, b) { a + b }
fn greet(name) { 'Hello, ' + name }

// With explicit types
fn abs(n Int) Int { if n < 0 { -n } else { n } }

type DivError { message String }

fn divide(a Int, b Int) Result(Int, DivError) {
    if b == 0 { Err(DivError(message: 'divide by zero')) }
    else { Ok(a / b) }
}

// Call functions
println(double(21))      // 42
println(add(10, 5))      // 15
println(greet('world'))  // Hello, world

Generics

Type variables are lowercase identifiers; concrete types start with an uppercase letter. The type checker infers concrete types at each call site, so the same function can be called with different types.

fn identity(x a) a { x }

fn first(arr Array(a)) Option(a) {
    match arr {
        [] -> None
        [head, ..] -> Some(head)
    }
}

fn map_array(arr Array(a), f fn(a) b) Array(b) {
    match arr {
        [] -> []
        [head, ..rest] -> [f(head), ..map_array(rest, f)]
    }
}

// Same function, different types
n = identity(42)        // Int
s = identity('hello')   // String
head = first([1, 2, 3]) or 0
doubled = map_array([1, 2, 3], fn(x) x * 2)  // [2, 4, 6]

println('${n} ${s}')  // 42 hello
println(head)          // 1
println(doubled)       // [2, 4, 6]

First-class functions and lambdas

Functions are values. Anonymous functions (fn(...) body) infer their return type and don't need braces for a single-expression body.

fn apply(x, f) { f(x) }
fn compose(f, g) { fn(x) f(g(x)) }

// Braceless lambda — body starts right after )
double = fn(n) n * 2
add_one = fn(n) n + 1

result = apply(5, double)  // 10
println(result)            // 10

// Compose functions
double_then_add = compose(add_one, double)
println(double_then_add(5))  // 11

// Higher-order patterns
fn twice(x, f) { f(f(x)) }
println(twice(3, fn(n) n + 1))  // 5

Recursion

Functions can call themselves. When the recursive call is the last thing a function does, AL reuses the stack frame — no stack overflow on deep recursion.

fn factorial(n Int) Int {
    if n <= 1 { 1 }
    else { n * factorial(n - 1) }  // not tail recursive (n * ...)
}

// Tail-recursive with accumulator — constant stack space
fn factorial_iter(n Int, acc Int) Int {
    if n <= 1 { acc }
    else { factorial_iter(n - 1, n * acc) }  // tail call
}

fn sum(arr Array(Int), acc Int) Int {
    match arr {
        [] -> acc
        [head, ..rest] -> sum(rest, acc + head)  // tail call
    }
}

println(factorial(5))              // 120
println(factorial_iter(5, 1))      // 120
println(sum([1, 2, 3, 4, 5], 0))   // 15

Everything is an expression

If, match, and blocks all return values. Every if requires an else. The last expression in a block is its value.

x = 5

result = if x > 0 {
    'positive'
} else {
    'non-positive'
}

// Blocks are expressions — and double as grouping
total = {
    a = 10
    b = 20
    a + b
}
nine = {1 + 2} * 3

// Use in function bodies
fn abs(n Int) Int {
    if n < 0 { -n } else { n }
}

println(result)  // positive
println(total)   // 30
println(nine)    // 9

Pattern matching

Match on values, or-patterns, constructors, literal payloads, and arrays. Exhaustive checking ensures you handle all cases.

fn describe(x Int) String {
    match x {
        0 -> 'zero'
        1 | 2 | 3 -> 'small'
        else -> 'other'
    }
}

// Match on arrays
fn sum(arr Array(Int)) Int {
    match arr {
        [] -> 0
        [head, ..tail] -> head + sum(tail)
    }
}

// Guards: pattern matches first, then condition filters
fn classify(n Int) String {
    match n {
        x if x < 0 -> 'negative'
        0 -> 'zero'
        else -> 'positive'
    }
}

Constructor pattern matching

Match on type constructors and bind payload values. Match literal payloads for specific cases.

type Reply {
    Ack(value String)
    Nack(value String)
}

fn handle(r Reply) String {
    match r {
        Ack('special') -> 'matched literal!'
        Ack(value) -> 'got: ${value}'
        Nack(e) -> 'failed: ${e}'
    }
}

println(handle(Ack('special')))  // 'matched literal!'
println(handle(Ack('hello')))    // 'got: hello'
println(handle(Nack('oops')))    // 'failed: oops'

Types

Define data with the 'type' keyword. Every field has a label. Single-constructor types may list fields directly; access with dot notation.

type Person {
    name String
    age Int
}

type Point { x Int y Int }

// Create instances — constructors are function calls
person = Person(name: 'alice', age: 30)
origin = Point(x: 0, y: 0)

// Access fields
println(person.name)  // alice
println(origin.x)     // 0

// Spread to copy with overrides
older = Person(..person, age: 31)
println(older.age)    // 31

Generic types

Types can have type parameters. Type arguments are inferred from field values.

type Box(t) { value t }

type Pair(a, b) {
    first a
    second b
}

// Type args inferred from values
int_box = Box(value: 42)
pair = Pair(first: 'hello', second: 123)

println(int_box.value)  // 42
println(pair.first)     // hello

Multiple constructors

A type with multiple constructors models alternatives. Constructors are first-class — they're just functions.

type Status {
    Active
    Inactive
    Banned(reason String)
}

type Shape {
    Circle(r Float)
    Rect(w Float h Float)
}

// Create values
status = Active
_banned = Banned('spam')
_c = Circle(r: 1.0)
println(status)  // Active

// Constructors are functions — pass them around
fn map3(a, b, c, f) { [f(a), f(b), f(c)] }
xs = map3(1.0, 2.0, 3.0, Circle)
println(xs)

Generic constructors

Constructor types can have type parameters for flexible data modeling.

type Maybe(t) {
    Just(value t)
    Nothing
}

type Either(l, r) {
    Left(value l)
    Right(value r)
}

// Type inferred from usage
x = Just(42)
y = Nothing

result = Left('failed')

println(x)       // Just(42)
println(y)       // Nothing
println(result)  // Left(failed)

Type aliases

An alias is a transparent second name for a type — they unify freely. Use a single-constructor type instead for a distinct newtype.

type Pair(a, b) { first a second b }

type Id = Int
type StringPair = Pair(String, String)

// Alias: Email IS String
type Email = String

// Newtype: UserId is distinct from Int
type UserId { UserId(value Int) }

Tuples

Fixed-size collections of mixed types with two or more elements. Access elements by index.

// Create tuples (always 2+ elements; (e) is a syntax error)
pair = (1, 'hello')
_triple = (True, 42, 'world')

// Access by index
first = pair.0   // 1
second = pair.1  // 'hello'
println('${first} ${second}')  // 1 hello

// Return from functions
fn divide(a, b) { (a / b, a % b) }

// Destructure with parentheses
(quotient, remainder) = divide(10, 3)
println('${quotient} r${remainder}')  // 3 r1

Arrays

Ordered collections of values. Indexing returns Option(t). Build and concatenate with spread.

numbers = [1, 2, 3, 4, 5]
names = ['alice', 'bob', 'charlie']

// Indexing returns Option — unwrap with 'or'
first = numbers[0] or 0       // 1
second = names[1] or 'anon'   // 'bob'
println('${first} ${second}')  // 1 bob

// Build with spread
combined = [..[1, 2], ..[3, 4]]  // [1, 2, 3, 4]
more = [0, ..numbers, 6]         // [0, 1, 2, 3, 4, 5, 6]
println(combined)                // [1, 2, 3, 4]
println(more)                    // [0, 1, 2, 3, 4, 5, 6]

// Nested arrays
_matrix = [[1, 2], [3, 4]]

Ranges

Create ranges with the '..' operator. Useful for iteration patterns.

// Create a range
r = 0..10
println(r[3] or -1)  // 3

// Ranges in expressions
fn in_range(n Int, start Int, end Int) Bool {
    n >= start && n < end
}
println(in_range(5, 0, 10))  // True

Binaries

The Binary type holds raw bits. Construct with '<<>>' — each bare segment is one byte. Match the same way; a length mismatch falls through, so an 'else' or ':binary' tail is required.

import al/binary
import al/string

// Three bytes
header = <<1, 2, 3>>
println(binary.byte_size(header))   // 3

// Bit-sized segments — two 4-bit nibbles pack into one byte
packed = <<1:4, 2:4>>
println(string.inspect(packed))     // <<18>>

// Match on structure: a and b each bind one byte
sum = match binary.from_string('AB') {
    <<a, b>> -> a + b
    else -> 0
}
println(sum)  // 131  (65 + 66)

// ':binary' binds the variable-length remainder
fn drop_one(b Binary) Binary {
    match b {
        <<_, rest:binary>> -> rest
        else -> b
    }
}

Strings and interpolation

Strings use single quotes. Embed expressions with $ for variables or ${} for complex expressions.

type Person { name String age Int }

name = 'world'
greeting = 'Hello, $name!'
math = 'Result: ${1 + 2 * 3}'
println(greeting)  // Hello, world!
println(math)      // Result: 7

// Multi-part interpolation
person = Person(name: 'Alice', age: 30)
bio = '${person.name} is ${person.age} years old'
println(bio)       // Alice is 30 years old

// Escape sequences
_quote = 'She said \'hello\''

Optional values

Option(t) models a value that might be absent. The constructors Some and None are in the prelude. Unwrap with 'or'.

type User { id Int name String }

fn find_user(id Int) Option(User) {
    if id == 0 { None }
    else { Some(User(id: id, name: 'found')) }
}

// Provide a default with 'or'
user = find_user(0) or User(id: 0, name: 'guest')
println(user.name)  // guest

// Array indexing returns Option too
names = ['alice', 'bob']
who = names[5] or 'nobody'
println(who)        // nobody

Result and error handling

Result(t, e) models success or failure. The constructors Ok and Err are in the prelude. Unwrap with 'or', optionally binding the error value.

type DivisionError { message String }

fn divide(a Int, b Int) Result(Int, DivisionError) {
    if b == 0 {
        Err(DivisionError(message: 'divide by zero'))
    } else {
        Ok(a / b)
    }
}

// Provide default on failure
safe = divide(10, 0) or 0
println(safe)    // 0

// Bind the error value
result = divide(10, 0) or err -> {
    println('Failed: ${err.message}')
    0
}
println(result)  // 0

Modules and imports

One file is one module. Mark exports with 'pub'. Import by path; access via the qualifier or bring names in directly.

// === main.al ===
import ./util
import ./util as u
import ./util.{quote, Id}
import al/net/socket.{Socket}   // standard library

println(util.quote('hi'))   // qualified
println(u.quote('hi'))      // module alias
println(quote('hi'))        // selective import

// === util.al ===
// pub fn quote(s String) String { '"' + s + '"' }
// pub type Id { Id(value Int) }

Tuple and range patterns

Match on tuple structure and integer ranges. 'else' is the catch-all arm; '_' is for nested wildcards.

fn classify(p (Int, String)) String {
    match p {
        (0, msg) -> 'zero: ${msg}'
        (1..10, msg) -> 'small: ${msg}'
        (_, msg) -> 'other: ${msg}'
    }
}

fn label(x Int) String {
    match x {
        0..10 -> 'digit'
        10..100 -> 'tens'
        else -> 'big'
    }
}

println(classify((5, 'hi')))
println(label(42))

Mutual recursion

Top-level 'fn' declarations see one another regardless of order.

fn is_even(n Int) Bool {
    if n == 0 { True } else { is_odd(n - 1) }
}

fn is_odd(n Int) Bool {
    if n == 0 { False } else { is_even(n - 1) }
}

println(is_even(10))
println(is_odd(7))

Field-exhaustive constructor patterns

Positional patterns must match arity; labeled patterns may use '..' to ignore the rest.

type User { name String age Int email String }

fn name_of(u User) String {
    match u {
        User(name: n, ..) -> n
    }
}

u = User(name: 'alice', age: 30, email: 'a@b.c')
println(name_of(u))

Discards and unused variables

Unused let-bindings and parameters are an error. Prefix a name with '_' to mark it intentional, or write 'TypeName = expr' to assert a type and drop the value. Single-constructor types can be destructured at binding position.

// '_' prefix marks a binding as intentionally unused
fn handle(_conn, msg) { println(msg) }

// Typed discard — checks the type, then drops the value
Nil = println('side effect')   // println returns Nil
Int = 1 + 2                    // asserts the rhs is Int

// Single-constructor types destructure irrefutably
type Point { x Int y Int }

Point(x: a, y: b) = Point(x: 3, y: 4)
println(a + b)  // 7

Built-in functions

Core functions available without imports.

import al/string

// Print any value
println(42)
println('hello')
println([1, 2, 3])

// Convert to string representation
s = string.inspect([1, 2, 3])  // '[1, 2, 3]'
println(s)

// Strings module
parts = string.split('a,b,c', ',')  // ['a', 'b', 'c']
n = string.length('hello')          // 5
has = string.contains('hello', 'ell')  // True
println(parts)           // [a, b, c]
println('${n} ${has}')   // 5 True

Floats

Float arithmetic uses the same operators as Int. The 'al/float' module has conversions and helpers.

import al/float

// Float to Int
println(float.round(2.7))     // 3
println(float.floor(2.7))     // 2
println(float.ceil(2.1))      // 3
println(float.truncate(2.9))  // 2

// Int to Float
half = float.from_int(1) / 2.0

// Helpers
diff = 1.0 - 3.5
println(float.abs(diff))       // 2.5
println(float.max(1.2, 3.4))   // 3.4
println(float.to_string(half))

I/O operations (experimental)

File and network I/O requires the --experimental-shitty-io flag.

// Run with: al run --experimental-shitty-io file.al
import al/io
import al/net
import al/net/socket.{Socket}
import al/net/address
import al/binary

// Text helpers wrap the Binary I/O
content = io.read_text('data.txt') or ''
println(content)

// TCP — accept gives you a Socket that knows its peer address
fn respond(sock Socket) Nil {
    body = 'Hello from AL!'
    socket.write(sock, binary.from_string('HTTP/1.1 200 OK\r\n\r\n${body}')) or Nil
    socket.close(sock) or Nil
}

match net.listen('0.0.0.0', 8080) {
    Err(e) -> println('listen failed: ${e}')
    Ok(server) -> match net.accept(server) {
        Ok(sock) -> {
            println('connection from ${address.to_string(sock.peer)}')
            respond(sock)
        }
        Err(e) -> println('accept failed: ${e}')
    }
}

Command reference

al run <file.al>

Type-check, compile, and run a program. Add --experimental-shitty-io for file/network I/O.

al check <file.al>

Type-check without running. Useful for IDE integration.

al fmt [path]

Format source files. Use --check to verify without modifying, --stdin to read from stdin.

al repl

Start an interactive session. Definitions persist across entries.

al lsp

Start the Language Server Protocol server for IDE integration.