The Palladium Tutorial

August 30, 2026 · View on GitHub

Every unmarked Palladium block in this file is compiled and run by scripts/check-docs.sh. If an unmarked snippet is here, the compiler accepts it. That is not a courtesy — this repository previously shipped documentation in which 508 of 560 snippets did not compile, describing a language that did not exist. The checker is how that stays fixed.

Blocks marked no-compile are deliberate counter-examples showing what the compiler rejects; they are excluded from the check.

1. Hello

fn main() {
    print("Hello, Palladium!");
}
pdc compile hello.pd -o hello
./build_output/hello

print writes a string and a newline. print_int writes an integer.

2. Variables and types

Bindings are immutable unless you write mut.

fn main() {
    let x: i64 = 42;
    let mut count: i64 = 0;
    count = count + 1;

    let name: String = "Palladium";
    let ready: bool = true;

    print_int(x);
    print_int(count);
    print(name);
    if ready {
        print("ready");
    }
}

Type annotations on let are optional; the compiler infers literals, calls, struct and enum values, references, field and index expressions. Where it has no rule it now says so:

error: cannot infer the type of `r`: no type rule for this range expression.
       Add an explicit type annotation, e.g. `let r: i64 = ...;`

That used to be a silent default to a 64-bit integer, which produced broken C for references, enum values and string copies. Annotating is still good practice in code you intend to keep — the bootstrap compiler annotates everywhere — but it is no longer load-bearing.

The primitive types are i64 (also spelled int), i32, u32, u64, bool, and String. There are no floating-point types and no char.

3. Functions

fn add(a: i64, b: i64) -> i64 {
    return a + b;
}

fn greet(name: String) {
    print("Hello,");
    print(name);
}

fn main() {
    print_int(add(10, 20));
    greet("world");
}

Write return explicitly. A trailing expression does work, but being explicit costs nothing and this language spent a year silently discarding tail expressions — and still discards the tail of an if, so a function whose body is if … { … } else { … } returns garbage (language-spec.md A6.6).

Functions may be called before they are defined — the compiler emits C prototypes for you. Mutual recursion works:

fn is_even(n: i64) -> bool {
    if n == 0 {
        return true;
    }
    return is_odd(n - 1);
}

fn is_odd(n: i64) -> bool {
    if n == 0 {
        return false;
    }
    return is_even(n - 1);
}

fn main() {
    if is_even(10) {
        print("10 is even");
    }
}

Always put spaces around a binary -. The lexer's integer rule includes the sign, so n-1 tokenises as n followed by -1 and misparses. n - 1 is correct.

4. Control flow

fn classify(n: i64) -> String {
    if n < 0 {
        return "negative";
    } else {
        if n == 0 {
            return "zero";
        } else {
            return "positive";
        }
    }
}

fn main() {
    print(classify(-5));
    print(classify(0));
    print(classify(7));

    let mut i: i64 = 0;
    while i < 3 {
        print_int(i);
        i = i + 1;
    }

    for j in 0..3 {
        print_int(j * 10);
    }
}

There is no else if. Nest the if inside the else block, as above. There is also no loop keyword — use while true. And there is no +=; write i = i + 1.

This is rejected:

fn main() {
    let x: i64 = 5;
    if x > 9 {
        print("big");
    } else if x > 1 {     // error: Expected '{' after else
        print("medium");
    }
}

5. Arrays

Arrays are fixed size. There is no Vec; carry an explicit count alongside the array.

fn sum_first(mut values: [i64; 8], n: i64) -> i64 {
    let mut total: i64 = 0;
    let mut i: i64 = 0;
    while i < n {
        total = total + values[i];
        i = i + 1;
    }
    return total;
}

fn main() {
    let mut values: [i64; 8] = [0; 8];
    let mut count: i64 = 0;

    values[count] = 10;
    count = count + 1;
    values[count] = 20;
    count = count + 1;
    values[count] = 12;
    count = count + 1;

    print_int(count);
    print_int(sum_first(values, count));
}

Two rules make array parameters behave:

  • Declare them mut. An array parameter lowers to a C pointer, so the callee writes through to the caller's array — declaring it mut is what tells the borrow checker the same thing the code generator already believes.
  • Iterate a parameter with while and an index, not for. for over an array parameter currently emits sizeof(arr)/sizeof(arr[0]) on a pointer that has already decayed, which is wrong. for over a literal range is fine.

6. Structs

struct Point {
    x: i64,
    y: i64,
    label: String,
}

fn shift(mut p: Point, dx: i64, dy: i64) {
    p.x = p.x + dx;
    p.y = p.y + dy;
}

fn describe(mut p: Point) -> i64 {
    print(p.label);
    return p.x * p.y;
}

fn main() {
    let mut origin: Point = Point { x: 3, y: 4, label: "corner" };
    shift(origin, 1, 1);
    print_int(origin.x);
    print_int(origin.y);
    print_int(describe(origin));
}

A struct parameter declared mut becomes a pointer in C, so mutations are visible to the caller — that is how shift works. Struct fields may be integers, booleans, strings, arrays, and other structs. They may not be tuples, references, or generic types.

7. Enums and pattern matching

enum Shape {
    Circle(i64),
    Rect(i64, i64),
    Empty,
}

fn area(s: Shape) -> i64 {
    match s {
        Shape::Circle(r) => {
            return 3 * r * r;
        }
        Shape::Rect(w, h) => {
            return w * h;
        }
        Shape::Empty => {
            return 0;
        }
    }
}

fn main() {
    let c: Shape = Shape::Circle(2);
    let r: Shape = Shape::Rect(3, 4);
    print_int(area(c));
    print_int(area(r));
}

match works on enums, integers, strings, bools and tuples. Literal patterns (1 =>, "s" =>, true =>), ranges (1..=9), or-patterns (A | B), @ bindings and arm guards (if cond) all work. What is still missing: slice patterns, ref/mut bindings, field shorthand in a struct-variant pattern (write Move { x: x, y: y }), and destructuring let.

A match must be exhaustive for EVERY scrutinee type, not only for enums — on an integer or a string that means a _ or a binding arm, because no set of literal or range arms is complete.

The annotations on let c: Shape = ... are optional — enum construction is inferred — but they document the intent, and match reads better when the scrutinee's type is stated nearby.

8. Strings

Strings are immutable handles. Build them with the builtins rather than operators.

fn main() {
    let a: String = "Palla";
    let b: String = "dium";
    let joined: String = string_concat(a, b);

    print(joined);
    print_int(string_len(joined));

    if string_eq(joined, "Palladium") {
        print("match");
    }

    let first: char = string_char_at(joined, 0);
    print_int(first as i64);

    let head: String = string_substring(joined, 0, 5);
    print(head);

    print(int_to_string(42));
    print_int(string_to_int("99"));
}

string_char_at returns a char (N4-04), and the classification predicates take one — so they compose with no cast. as i64 is how you get at the code point, and how you print it:

fn count_digits(s: String) -> i64 {
    let mut n: i64 = 0;
    let mut i: i64 = 0;
    let len: i64 = string_len(s);
    while i < len {
        let ch: char = string_char_at(s, i);
        if char_is_digit(ch) {
            n = n + 1;
        }
        i = i + 1;
    }
    return n;
}

fn main() {
    print_int(count_digits("a1b22c333"));
}

+ does work on strings, but string_concat is preferred: it keeps the meaning explicit and it is what the bootstrap compiler uses.

9. Files and program arguments

fn main() {
    let path: String = "/tmp/palladium_tutorial.txt";

    let handle: i64 = file_open(path);
    file_write(handle, "written from Palladium\n");
    file_close(handle);

    let back: String = read_file_to_string(path);
    print(back);
    print_int(string_len(back));
}

Command-line arguments follow C's convention — arg_count() includes the program name, so the first real argument is arg_at(1):

fn main() {
    let n: i64 = arg_count();
    if n < 2 {
        print("usage: prog <name>");
    } else {
        print(string_concat("hello, ", arg_at(1)));
    }
}

10. References

fn main() {
    let x: i64 = 42;
    let y: &i64 = &x;
    print_int(*y);

    let inferred = &x;      // the annotation is optional
    print_int(*inferred);
}

Note that the type checker does not distinguish &T from T — there is no reference type in it at all. Borrow checking happens, and code generation emits real pointers, but references are not yet part of the type system. One consequence: dereferencing a reference parameter emits a double dereference and fails in C, so pass values or arrays rather than &T parameters for now.

11. A whole program

A word-frequency-style counter, using every mechanism above: arrays with a count, string scanning, a struct for state, and an explicit while loop.

struct Counter {
    starts: [i64; 32],
    lengths: [i64; 32],
    n: i64,
}

fn add_word(mut c: Counter, start: i64, length: i64) {
    if c.n < 32 {
        c.starts[c.n] = start;
        c.lengths[c.n] = length;
        c.n = c.n + 1;
    }
}

fn split_words(mut c: Counter, text: String) {
    let len: i64 = string_len(text);
    let mut i: i64 = 0;
    while i < len {
        let ch: char = string_char_at(text, i);
        if char_is_whitespace(ch) {
            i = i + 1;
        } else {
            let start: i64 = i;
            while i < len {
                if char_is_whitespace(string_char_at(text, i)) {
                    break;
                }
                i = i + 1;
            }
            add_word(c, start, i - start);
        }
    }
}

fn longest(mut c: Counter) -> i64 {
    let mut best: i64 = 0;
    let mut i: i64 = 0;
    while i < c.n {
        if c.lengths[i] > best {
            best = c.lengths[i];
        }
        i = i + 1;
    }
    return best;
}

fn main() {
    let mut c: Counter = Counter { starts: [0; 32], lengths: [0; 32], n: 0 };
    let text: String = "the quick brown fox jumped over lazy dogs";

    split_words(c, text);

    print("words:");
    print_int(c.n);
    print("longest:");
    print_int(longest(c));

    let mut i: i64 = 0;
    while i < c.n {
        print(string_substring(text, c.starts[i], c.starts[i] + c.lengths[i]));
        i = i + 1;
    }
}

12. What the language does not have

Being clear about this is the point of the document. None of the following work today, and several of them fail without an error message — see the language specification for the exact failure mode of each.

Not availableUse instead
else ifnested if inside else
loopwhile true
+=, -=, *=i = i + 1
method calls x.f()Type::f(x)
traitsnothing — they parse and emit no code at all
genericsnothing — monomorphisation is partial and argument parsing is buggy
closurestop-level functions
Vec, HashMapfixed-size arrays with a count
Option, Resultyour own enum
? operatorexplicit checks — ? is rejected: "the ? operator is not implemented"
async / .awaitnothing — .await is rejected: ".await is not implemented"
tuplesa struct
floats, chari64
bitwise & | ^ << >>nothing — the lexer has no such tokens
as castsnothing
string interpolationstring_concat
literal patterns in matchif/else chains

Next