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

   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 full type inference. Every expression has a type known at compile time. The type checker catches errors before your code runs, while inference keeps the syntax clean—no type annotations needed for local variables or even function parameters.

The compiler is written in V, 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 supports generics via type variables, closures, tagged enums with pattern matching, first-class functions, and a unified error handling model where both optional values and errors are handled with the same or syntax.

Quick start

Create a file called hello.al:

struct 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
nothing = none       // None

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

// Arrays
numbers = [1, 2, 3, 4, 5]
first = numbers[0]

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

Basic operators

Standard arithmetic, comparison, and logical operators.

// Arithmetic
sum = 1 + 2
diff = 5 - 3
prod = 4 * 2
quot = 10 / 3
rem = 10 % 3

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

Functions with type inference

Function parameter and return types are inferred from usage. Add explicit types when needed for clarity or error types.

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

// Explicit types when needed
fn divide(a Int, b Int) Int!Error {
    if b == 0 { error Error{ message: 'divide by zero' } }
    else { a / b }
}

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

Generics

Use lowercase type variables for polymorphic functions. The type checker infers concrete types at each call site.

fn identity(x a) a { x }

fn first(arr []a) ?a {
    match arr {
        [] -> none,
        [head, ..] -> head,
    }
}

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

// Works with any type
n = identity(42)        // Int
s = identity('hello')   // String
head = first([1, 2, 3]) or 0

First-class functions

Functions are values. Pass them around, store them in variables, return them from functions.

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

double = fn(n) { n * 2 }
add_one = fn(n) { n + 1 }

result = apply(5, double)  // 10

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

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

Recursion

Functions can call themselves. AL optimizes tail recursion.

fn factorial(n Int) Int {
    if n <= 1 { 1 }
    else { n * factorial(n - 1) }
}

fn fibonacci(n Int) Int {
    match n {
        0 -> 0,
        1 -> 1,
        else -> fibonacci(n - 1) + fibonacci(n - 2),
    }
}

println(factorial(5))   // 120
println(fibonacci(10))  // 55

Everything is an expression

No statements. If/else, match, and blocks all return values. The last expression in a block is its value.

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

// Blocks are expressions
total = {
    a = 10
    b = 20
    a + b
}

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

Pattern matching

Match on values, or-patterns, enums, 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 []Int) Int {
    match arr {
        [] -> 0,
        [head, ..tail] -> head + sum(tail),
    }
}

// Wildcard pattern
fn ignore_second(pair) {
    match pair {
        [a, _] -> a,
        else -> none,
    }
}

Enum pattern matching

Match on enum variants and bind payload values. Match literal payloads for specific cases.

enum Result {
    Ok(String)
    Err(String)
}

fn handle(r Result) String {
    match r {
        Ok('special') -> 'matched literal!',
        Ok(value) -> 'got: $value',
        Err(e) -> 'error: $e',
    }
}

// Use it
handle(Ok('special'))  // 'matched literal!'
handle(Ok('hello'))    // 'got: hello'
handle(Err('oops'))    // 'error: oops'

Structs

Define data structures with named fields. Access fields with dot notation.

struct Person {
    name String
    age Int
}

struct Point {
    x Int
    y Int
}

// Create instances
person = Person{ name: 'alice', age: 30 }
origin = Point{ x: 0, y: 0 }

// Access fields
println(person.name)  // alice
println(person.age)   // 30

Generic structs

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

struct Box(t) {
    value t
}

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

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

// Or specify explicitly
Box(String){ value: 'world' }

Enums

Model variants with enums. Variants can carry payloads of any type.

enum Status {
    Active
    Inactive
    Banned(String)
}

enum Option {
    Some(Int)
    Empty
}

// Create enum values
status = Status.Active
banned = Status.Banned('spam')
some_value = Some(42)  // Short form

Generic enums

Enums can have type parameters for flexible data modeling.

enum Maybe(t) {
    Just(t)
    Nothing
}

enum Result(ok, err) {
    Ok(ok)
    Err(err)
}

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

result Result = Ok('success')

Tuples

Fixed-size collections of mixed types. Access elements by index.

// Create tuples
pair = (1, 'hello')
triple = (true, 42, 'world')

// Access by index
first = pair.0   // 1
second = pair.1  // 'hello'

// In function returns
fn divide(a Int, b Int) (Int, Int) {
    (a / b, a % b)
}

quotient, remainder = divide(10, 3)

Arrays

Ordered collections of values. Access by index, concatenate with +.

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

// Access by index
first = numbers[0]  // 1
second = names[1]   // 'bob'

// Concatenate
combined = [1, 2] + [3, 4]  // [1, 2, 3, 4]

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

Ranges

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

// Create a range
r = 0..10

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

Strings and interpolation

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

name = 'world'
greeting = 'Hello, $name!'
math = 'Result: ${1 + 2 * 3}'

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

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

Optional values

Functions that might not return a value use ? in their return type. Handle missing values with 'or'.

fn find_user(id Int) ?User {
    if id == 0 { none }
    else { User{ id: id, name: 'found' } }
}

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

// Handle with receiver
result = find_user(0) or missing -> {
    println('User not found')
    User{ id: -1, name: 'default' }
}

Error handling

Functions that can fail use ! with an error type. Handle errors with 'or', optionally binding the error.

struct DivisionError {
    message String
}

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

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

// Handle error with receiver
result = divide(10, 0) or err -> {
    println('Error: ${err.message}')
    -1
}

Built-in functions

Core functions available without imports.

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

// Convert to string representation
s = inspect(Person{ name: 'alice', age: 30 })

// Split strings
parts = str_split('a,b,c', ',')  // ['a', 'b', 'c']

I/O operations (experimental)

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

// Run with: al run --experimental-shitty-io file.al

// File operations
content = read_file('data.txt')
write_file('output.txt', 'hello world')

// TCP networking
listener = tcp_listen(8080)
client = tcp_accept(listener)
data = tcp_read(client)
tcp_write(client, 'HTTP/1.1 200 OK\r\n\r\nHello')
tcp_close(client)

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.