Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Ferlium is a small, statically-typed programming language designed for education and embedding. It aims to combine the expressiveness of Haskell with a syntax inspired by Rust and other C-style languages, while remaining approachable like JavaScript and Python. Ferlium is expression-based, uses Hindley–Milner style type inference, and supports pattern matching and mutable value semantics.

This book introduces the Ferlium language and its features. It is intended for both new users who want to learn the language and experienced programmers who want to understand its design and capabilities.

This introduction provides a brief overview of Ferlium’s syntax and core concepts; later chapters explore the language in more depth, covering the core language, structured data and abstraction, the type system, and effects and mutability.

A first taste

fn sign(n) {
	if n < 0 {
		-1
	} else if n > 0 {
		1
	} else {
		0
	}
}

let x = 3;
sign(x)

Ferlium programs are expressions. This means that constructs such as blocks, conditionals, and function bodies always produce a value. The value of a block is the value of its last expression; when there is no meaningful result, the value is the unit value, equivalent to the empty tuple (). For this reason, functions in Ferlium always return a value.

Comments

Ferlium supports C-style single-line and block comments:

// Single-line comment
/* Block comment */

Values and basic literals

Ferlium has the following basic literal forms:

let i = 42;        // int
let f = 3.14;      // float
let b = true;      // bool
let s = "hello";   // string
let u = ();        // unit

Formatted strings use f"..." and can refer to bindings in scope:

let a = 1;
let b = true;
f"hello {a} world {b}"

Bindings and mutability

Use let for immutable bindings and let mut for mutable bindings. You can think of a binding as a named cell: let creates a read-only cell to a value, let mut a mutable cell to a value, that is, one that can be modified after creation:

let a = 1;
let mut b = 0;
b = b + 1;
b

Blocks and sequencing

Blocks are delimited by {} and are composed of a sequence of statements, each ending with a semicolon, optionally followed by an expression. A statement is either a binding or an expression whose value is discarded. The value of a block is the value of its optional last expression:

{
	let x = 1;
	let y = 2;
	x + y
}

or () if the block ends with a statement:

{
	let x = 1;
	let y = 2;
}

Functions

Define a function with fn. Arguments are comma-separated. A function body is a block, and its value is the return value.

fn add(a, b) {
	a + b
}

add(1, 2)

Functions have static types that are inferred. Ferlium may automatically generalize functions when possible. For example, a function like add may be inferred as taking any numeric type, not just int. The details of generics and constraints are explained later in this book.

If you want to limit the function to specific types, you can add type annotations to arguments and return types:

fn add(a: int, b: int) -> int {
	a + b
}

How to read and use the examples

This book contains many small Ferlium code examples. They are written to illustrate specific concepts and are not all meant to be run as complete programs.

Some examples show standalone expressions, such as:

1 + 2 * 3

Others show script-style code with bindings and sequencing, such as:

let x = 10;
let y = 20;
x + y

Not all examples are runnable as-is. Some omit surrounding context for clarity, and some show multiple expressions without separators to emphasize their individual results. Runnable examples are explicitly marked when relevant.

You can run the examples in Ferlium’s playground or in a local Ferlium REPL, as explained in the README. Running directly in this document is not supported at this time.

As you read, focus on what each example is meant to illustrate, rather than treating every code block as a complete program.

What comes next

The next chapter walks you through running your first Ferlium code and seeing how the language executes.

Getting Started

In this chapter you will run Ferlium code for the first time, evaluate a few expressions, and see how the different execution methods report results and errors.

Run Ferlium

You can interact with Ferlium in three common ways:

  • Online playground: visit the Ferlium playground and type code in the editor; results appear on the bottom.
  • REPL: start the interactive prompt with cargo run --example ferlium, then type expressions and see their results.
  • Local execution: put code in a file and run it locally with cat FILENAME | cargo run --example ferlium (Unix shell).

This chapter focuses on what to type once you are able to run Ferlium code using one of these methods.

Your first expression

Enter a simple expression and observe its value.

1 + 2

Result: 3

A small script

A multi-line script can build a value step by step. The last expression is the result of the script.

let mut total = 0;
total = total + 1;
total = total + 2;
total

Result: 3

A simple function

Define a function and call it.

fn double(x) { x * 2 }

double(21)

Result: 42

You can use the same function, and pass a floating-point number.

 fn double(x) { x * 2 }
double(1.5)

Result: 3.0

Seeing results

Ferlium always produces a value. What you see depends on how you run the code.

In the playground and the REPL, the value of the last expression is shown immediately. When running a local script, the value of the last expression in the file is the result produced by the script.

When reading examples in this book, focus on the value of the final expression.

A first error

If the types do not line up, Ferlium reports a compile-time error and points to the relevant spans. For example:

let x: int = true;
x

You will see a type-mismatch error stating that the true value has type bool, which does not match the int annotation, along with location information for both parts of the code.

What comes next

Continue with the next chapter on the core language to build on these basics.

Values and Types

Every Ferlium expression produces a value. Values are the runtime results of evaluating expressions, and the language is built around composing these values to form larger computations. This chapter explains what values are, the basic built-in types, and how Ferlium assigns and checks types.

What is a value?

A value is the result of evaluating an expression. Literals like 1 and "hello" are values, and so are the results of computations like 1 + 2 or abs(-3). Because Ferlium is expression-based, constructs such as blocks and conditionals also produce values.

Bindings name values:

let a = 1;
let b = a + 2;
b

Here, b evaluates to the value 3.

Static typing and inference

Ferlium is statically typed. The compiler determines a type for every expression and checks that types are used consistently. Most of the time you don’t need to write types explicitly because Ferlium infers them from how values are used.

You can still add type annotations to guide or clarify inference using ::

let n: int = 42;
let x = (1: int);

Type annotations can also be used in function signatures to specify parameter types:

fn add_int(x: int, y: int) {
    x + y
}

or return types, using ->:

fn add_int(x, y) -> int {
    x + y
}

If an expression cannot be assigned a consistent type, compilation fails with a type error. For example, this function has inconsistent types between its parameters and its return value:

fn bad(x: int) -> float {
    x
}

as int and float are different types.

Basic built-in types

These are the core, always-available types in Ferlium:

  • unit, written (), a type with a single value representing “no meaningful result”
  • bool with values true and false
  • int representing whole numbers
  • float representing floating-point numbers
  • string representing text

Literals for these types look like this:

let u = ();        // unit
let b = true;      // bool
let i = 42;        // int
let f = 3.14;      // float
let s = "hello";   // string

Formatted strings use f"..." and evaluate to a string:

let a = 1;
let ok = true;
f"a = {a}, ok = {ok}"

Composite values

Ferlium also includes a few composite value forms that you can use immediately.

Tuples

A tuple groups multiple values together. A single-element tuple requires a trailing comma.

let p = (1, true);
let single = (1,);

The corresponding types are written as (int, bool) and (int,).

Arrays

An array is written with brackets. All elements must have the same type.

let xs = [1, 2, 3];
let empty: [int] = [];

The type [int] means “array of integers”. Arrays can be composed of any type, including other arrays:

let matrix = [[1, 2], [3, 4]];

The type of matrix is [[int]], an array of arrays of integers.

Records

Records are field-based values with named entries.

let user = { name: "Ada", age: 36 };

The type of user is written { name: string, age: int }. Field order does not matter. Records can contain any types, including other records, tuples and arrays:

let cfg = { host: "localhost", port: 8080, paths: ["/", "/api"] };

The type of cfg is { host: string, port: int, paths: [string] }.

Variants

A variant is a value composed of a name with optional payload data. Each variant belongs to a sum type, which defines the possible alternatives.

let a = None;
let b = Some(1);

The corresponding type is written None | Some(int), a sum type with two alternatives: None and Some.

Unit and “no meaningful result”

The unit value () represents the absence of a meaningful result. It is equivalent to the empty tuple, and is the only value of type unit. It is used when you do something only for its effect (like assigning to a mutable variable) or when a block ends with a semicolon.

let mut v = 0;
let x = {
    let y = 1;
    v = y;
};

(x, v)

The block returns () because the last expression ends with ;. The tuple (x, v) evaluates to ((), 1).

How expressions are typed

Every expression has a type, and the compiler checks that they fit together:

  • Literals have fixed types (1.1 is float, "hi" is string).
  • An exception is integer literals, which can be used as any number type depending on context.
  • Operators require compatible operand types (for example, + on numbers of the same type).
  • In if expressions, both branches must have the same type.
  • In arrays, all elements must share a single element type.
  • In tuples and records, each position or field has its own type.
  • In function calls, argument types must match the function’s parameter types.

Type annotations can help when the compiler needs guidance:

let values: [int] = [];
let total = ((1 + 2): float);

Type errors and what they mean

A type error means the compiler could not make the types consistent. Common causes include:

  • Using an operator with incompatible types.
  • Combining branches that return different types.
  • Passing a value of the wrong type to a function.

For example:

1 + true                     // type error: int + bool
if true { 1 } else { "no" }  // type error: branches differ

Type errors are reported at compile time, before any code runs. Fixing them usually means making the types line up—by changing a value, adding an annotation, or restructuring the expression.

What comes next

The next chapter explores bindings, scope, and mutability in more detail, including how to create and work with mutable state.

Bindings, Scope, and Mutability

Ferlium lets you name values, limit where those names are visible, and decide which names refer to values that can be modified. This chapter explains let and let mut, how scopes work, and the intuitive model behind Ferlium’s mutable value semantics.

let bindings

A let binding introduces a new name for a value:

let x = 1;
let y = x + 2;
y

Bindings are immutable by default. That means you can read x, but you cannot modify the value of x later.

let mut bindings

Use let mut when you want a variable that can be modified after it is defined:

let mut counter = 0;
counter = counter + 1;
counter

Assignment itself produces the unit value ():

let mut a = 1;
a = 2;   // value is ()
a

Scope

Every block introduces a new scope. A name is visible from its definition to the end of the block where it is defined.

let x = 1;
{
    let y = x + 1;
    y
}

Here, y exists only inside the block. The value of the block is the value of its last expression.

Shadowing

You can reuse a name in an inner scope. The new binding temporarily hides the outer one. This is called shadowing.

let x = 1;
{
    let x = 2;
    x
}

The inner x does not change the outer x. When the block ends, the outer x is visible again.

You can also shadow a name in the same scope:

let x = 1;
let x = x + 1;
x

In this case, the first x is not accessible after the second x is defined. The type of the new x can be different from the old one. For example, you can shadow an integer with a string:

let x = 1;
let x = f"number {x}";
x

Shadowing can be done even though the binding is not mutable, because the new binding is a different name that happens to shadow the old one.

Function-local variables

Function parameters behave like local bindings and are scoped to the function body. Whether a function can modify a value depends on how that value is passed, which will be discussed later. Function parameters can also be shadowed:

fn add_one(n) {
    let n = n + 1;
    n
}

add_one(10)

This uses a new local n that shadows the parameter inside the function body.

Mutable value semantics

Think of a binding as a named cell that holds a value. With let, the cell is read-only. With let mut, the cell is writable. Reassigning a mutable binding replaces the value stored in that cell.

In Ferlium, values are not implicitly shared between bindings. Reassigning a mutable binding replaces the value stored in that binding, and updates to compound values (such as tuples or arrays) affect only that binding unless the value was explicitly passed in a mutable way. This model avoids hidden aliasing: two separate bindings do not unexpectedly refer to the same mutable storage.

Compound values can be updated through a mutable binding:

let mut p = (1, 2);
p.0 = 3;

let mut xs = [1, 2];
xs[0] = 3;

(p, xs)

If the binding is not mutable, these updates are rejected at compile time.

What comes next

The next chapter introduces functions and type inference, showing how Ferlium automatically determines types for your code.

Functions and Type Inference

Ferlium infers function types from their definitions and uses a whole-module view to keep types consistent. This chapter explains how inference works, why functions get the most general type possible, and how constraints arise from the operations inside function bodies.

Function types are inferred from bodies

When you define a function, Ferlium infers its input and output types by analyzing the body. You do not need to write a type signature to get a well-typed function.

fn abs1(x) {
    if x < 0 {
        -x
    } else {
        x
    }
}

abs1(-3)

Here, the comparison and negation determine that x must be a number, and the result has the same type as x.

Automatic generalization

At the module level, Ferlium generalizes function types to be as general as possible. Intuitively, a function that does not depend on a specific concrete type becomes usable with many types.

fn id(x) { x }

(id(1), id(true), id("hi"))

id is inferred once, and the compiler makes it as general as it can be so that all valid uses can share the same definition.

Constraints from operations

Operations inside a function body create requirements on the types involved. For example, + requires a numeric type, and comparisons like < require an ordered type. Ferlium records these requirements as constraints during inference.

fn inc(x) { x + 1 }

(inc(41), inc(41.5))

The function inc is inferred with the constraint that its argument supports addition with a numeric literal. This is why it works for numeric types but not for bool or string.

Whole-module inference

Ferlium infers types for all functions in a module together. Functions can refer to each other regardless of their order, including mutual recursion.

fn is_even(n) {
    if n == 0 { true } else { is_odd(n - 1) }
}

fn is_odd(n) {
    if n == 0 { false } else { is_even(n - 1) }
}

is_even(10)

The compiler resolves is_even and is_odd as a pair, so each function influences the inferred type of the other.

How annotations interact with inference

Type annotations restrict the inferred type and are checked for consistency.

fn add_one(x: int) -> int { x + 1 }

add_one(10)

Annotations are most useful when you want to fix a type to a specific one, or when you want to document intent. In all cases, the inferred type must agree with the annotation.

Recursive functions

A function in Ferlium can call itself. This is called recursion. Recursive functions are commonly used when a problem can be broken into smaller instances of the same problem.

For example, factorial can be defined recursively:

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

Here, fact calls itself with a smaller argument until it reaches the base case n <= 1.

Recursion works naturally with type inference. The compiler infers one type for the function and checks that all recursive calls are consistent with that type.

Ferlium cannot enforce at compile time that recursive functions terminate. If a function calls itself indefinitely, execution will fail at runtime. It is the programmer’s responsibility to ensure that recursion progresses toward a base case.

What comes next

In some cases, inference leaves certain types ambiguous. Later chapters explain how Ferlium resolves such ambiguities automatically, and explain explicit generics and the constraint system in more depth. The next chapter covers control flow.

Control Flow and Pattern Matching

Control flow in Ferlium is expression-based: if and match both produce values. This chapter explains how their types are determined and how to use them safely and clearly.

if is an expression

An if expression always produces a value. When both branches are present, the result is the value of the selected branch.

let x = if 1 < 2 { 10 } else { 20 };
x

The condition must be a bool. Both branches must produce compatible types, because the whole if has a single type.

if true { 1 } else { 2 }        // ok: int
if true { "yes" } else { "no" } // ok: string

Branch types must agree

If the branches have different types, the program does not type check:

if true { 1 } else { "no" }   // type error

if without else

An if without else is allowed when it is used for its effect. In that case, its result type is ().

let mut a = 0;
if true { a = 1 };
a

Trying to use a value-producing branch without an else is a type error:

// type error: missing else for a non-unit branch
fn f() { if true { 1 } }

match is an expression

A match expression selects one of several branches based on a value. Like if, it produces a value.

In this chapter, we only use literal patterns and the wildcard _.

let a = 0;
match a {
    0 => 1,
    1 => 2,
    _ => 3,
}

An entry LITERAL => EXPRESSION is called an arm.

All arms must have compatible types

Every arm in a match must produce a value of the same type, because the whole match has a single type:

match 0 {
    0 => 1,
    _ => 2,
}

This is a type error:

match 0 {
    0 => 1,
    _ => "no",
}

Exhaustiveness with _

When matching on literals, use _ as the default case unless you explicitly cover all possible values. For example, matching on bool can list both cases:

match true {
    true => 1,
    false => 0,
}

What comes next

Later chapters expand pattern matching to structured data and more powerful patterns. For now, you can use literals and _ to write clear, type-safe control flow.

Arrays, Ranges, and Iteration

Ferlium provides compact tools for working with sequences of values: arrays, ranges, and for loops. This chapter introduces how to create and read arrays, describe numeric sequences with ranges, and iterate over both forms in an expression-oriented style.

Arrays

Arrays store ordered values of a single element type.

Array literals

Array literals use square brackets:

let a = [1, 2, 3];
let b = [true, false, true];
let c = ["a", "b"];

A trailing comma is allowed:

let xs = [1, 2, 3,];

[] is valid syntax, but by itself its element type is unknown, so it needs context:

let empty: [int] = [];

Indexing

Use array[index] to access elements:

let xs = [10, 20, 30];
let first = xs[0];
let second = xs[1];

Negative indices count from the end:

let xs = [10, 20, 30];
let last = xs[-1];
let before_last = xs[-2];

Indexing out of bounds is a runtime error.

Arrays are values with one element type

Arrays are regular values: you can bind them, pass them around, and return them from expressions.

let xs = [1, 2, 3];
let ys = xs;
ys[0]

All elements must have the same type. Mixing multiple element types is a type error:

[1, true]   // type error

Element type inference

Ferlium infers the element type from array contents:

let ints = [1, 2, 3];      // inferred as [int]
let floats = [1.0, 2.5];   // inferred as [float]

For an empty array, add context with an annotation:

let mut out: [int] = [];

Ranges

Ranges are a compact way to describe integer sequences.

Exclusive and inclusive ranges

Use start..end for an exclusive upper bound:

let r = 1..4;   // 1, 2, 3

Use start..=end for an inclusive upper bound:

let r = 1..=4;  // 1, 2, 3, 4

Ranges also work in downward direction:

let r = 5..2;   // 5, 4, 3
let r = 5..=2;  // 5, 4, 3, 2

Iteration with for

for loops iterate over a sequence and execute a body for each element.

Iterating over ranges and arrays

Iteration works the same way for ranges and arrays:

for i in 0..3 { /* ... */ };
for x in [10, 20, 30] { /* ... */ };

Accumulating with let mut

A common pattern is to keep mutable state outside the loop and update it inside:

let mut sum = 0;
for i in 1..=4 {
    sum = sum + i;
};
sum

Collecting values works the same way:

let mut out: [int] = [];
for i in 2..5 {
    array_append(out, i);
};
out

Loop variable scope and expression result

The loop variable is local to the loop body. The for expression itself evaluates to ().

let mut count = 0;
for n in [1, 2, 3] {
    count = count + 1;
};
count

What comes next

The next chapters expand structured data beyond arrays and introduce richer pattern matching for structured values.

User-Defined Types

Ferlium lets you model domain data with your own types: product types for combining fields and sum types for choosing between alternatives. This chapter introduces these constructs and shows how naming types improves readability and safety.

Type Aliases

Type aliases give a name to an existing type expression:

type UserId = int;
type Point = (int, int);
type PersonView = { name: string, age: int };

Aliases improve readability, but they do not create a new nominal type. For type checking, they are treated as the underlying type.

Product Types

Product types group several values into a single value. They are called “product” types because the number of possible values is the product of the possibilities of their components.

Tuples

A tuple stores values by position.

let point = (10, 20);
let x = point.0;
let y = point.1;

Tuple access uses numeric projections (.0, .1, …).

let nested = (1, (3, (2, 4, 5)));
let value = nested.1.1.2;

Each element can have a different type:

let mixed = (1, "hello", true);

Records

A record stores values by field name.

let person = { name: "Ada", age: 36 };
let n = person.name;
let a = person.age;

Record access uses field projections (.field_name).

let cfg = { host: "localhost", port: 8080 };
cfg.port

Inference with product types

Inference works naturally with tuples and records: the compiler infers field and element types from their construction and usage.

let pair = (1, true);              // inferred as (int, bool)
let user = { name: "A", age: 30 }; // inferred as { name: string, age: int }

Sum Types

Sum types let a value be one of several alternatives. Each alternative — also called a variant — has a name and an optional payload of associated data. The name of the alternative is called a tag.

None           // tag: None, no data
Some(42)       // tag: Some, data: int
RGB(255, 0, 0) // tag: RGB, data: (int, int, int)

At runtime, a value of a sum type carries exactly one tag, together with the payload of that alternative. These types are called “sum” types because their number of possible values is the sum of the possibilities of their alternatives.

You can also define a sum type with an alias when you want to limit the alternatives to a specific set:

type Shape = Circle(float) | Rectangle { width: float, height: float };

let a: Shape = Circle(5.0);

Inference with sum types

Inference works with sum types as well. The compiler infers the type of a value from its construction and usage. For example, the function:

fn none() {
    None
}

returns a value whose type includes the None variant, because the caller may choose any compatible sum type that contains None.

If you want to specify a particular sum type, you can add an annotation:

fn none() -> None | Some(int) {
    None
}

As we will see later, matching on a sum type also narrows the type to the relevant alternative, which is how you can access the payload data. Also, this can constrain the set of valid alternatives.

Nominal Types

Nominal types, sometimes called “newtypes”, are defined with a name and a structure, and they are distinct from other types even if their underlying structure is the same. They make domain intent explicit and prevent accidental mixing of values that share the same underlying representation.

Nominal Product Types: struct

A struct defines a new nominal product type. It supports empty, tuple, and record forms:

struct Empty {}
struct Point(int, int)
struct Person { name: string, age: int }

Using a struct gives nominal identity to the type, so even if two structs have the same underlying fields, they are not the same type:

struct UserId(int)
struct ProductId(int)

let u = UserId(10);
let p = ProductId(10);

let raw = u.0;

Here, UserId and ProductId are distinct types, even though both wrap int.

Nominal Sum Types: enum

An enum defines a new nominal sum type. Each alternative can have its own payload:

enum Message {
    Quit,
    Write(string),
    Move { x: int, y: int }
}

Each variant (alternative) within an enum can be:

  • unit-like (no payload), as with Quit
  • tuple-like, as with Write(string)
  • record-like, as with Move { x: int, y: int }

Construction uses TypeName::VariantName:

 enum Message {
     Quit,
     Write(string),
     Move { x: int, y: int }
 }
let m1 = Message::Quit;
let m2 = Message::Write("hello");
let m3 = Message::Move { x: 10, y: 20 };

Structural vs Nominal Types

As seen in this chapter, Ferlium supports both structural and nominal reasoning.

  • Tuples, records and sum types are structural: compatibility depends on shape.
  • Named types — struct and enum — are nominal: compatibility depends on the declared type name rather than on structure alone.

Example:

struct Age(int)
struct Person1 { age: Age }
struct Person2 { age: Age }

fn age_value(d) { d.age.0 }      // works for values with compatible structure
fn age1(d: Person1) { d.age.0 }  // requires exactly Person1

age_value can be used with either Person1 or Person2 because it depends only on the required structure. age1 is explicitly nominal and accepts only Person1.

Note on Repr

Ferlium includes an internal marker concept called Repr that links a named type to the value representation it exposes. In practice, this is why projections and pattern matching behave uniformly across structural and nominal data, while named types remain distinct during type checking.

You do not write or define Repr yourself.

Algebraic Data Types

Product and sum types are often called algebraic data types because they can be combined in ways that mirror algebraic operations: products correspond to multiplication of possibilities, and sums correspond to addition of possibilities.

Contrary to most languages, Ferlium has complete and orthogonal coverage of both algebraic data types and structural/nominal types, so you can choose the right tool for the job without compromise.

What comes next

The next chapter expands pattern matching for structured data, so you can inspect and branch on tuples, records, and variants directly.

Pattern Matching

Pattern matching lets you branch on the shape or value of data. In Ferlium, match is an expression and is statically type-checked.

Match is an expression

A match expression selects one arm and returns that arm’s value.

let score = match true {
    true => 100,
    _ => 0,
};

score

Like if, a match expression has a single result type.

Basic patterns

Literal patterns

Literal patterns compare the matched value against a literal.

match 0 {
    -1 => "negative one",
    0 => "zero",
    _ => "other",
}

Ferlium supports the following scalar literal patterns: (), bool, int, and string.

Wildcard _

_ is the default pattern of a match. It matches any value not matched by earlier arms.

match 5 {
    0 => "zero",
    1 => "one",
    _ => "many",
}

Matching structured data

Product types

Tuple values can be matched using tuple patterns:

match (true, false) {
    (true, true) => 1,
    (true, false) => 2,
    (false, true) => 3,
    (false, false) => 4,
}

Nested tuple patterns are supported:

match (true, (false, true)) {
    (true, (false, true)) => 1,
    _ => 0,
}

Record values can also be matched using record literal patterns:

match { x: true, y: false } {
    { x: true,  y: true  } => 1,
    { x: true,  y: false } => 2,
    { x: false, y: true  } => 3,
    { x: false, y: false } => 4,
}

Field order in record patterns does not matter; fields are matched by name.

Sum types

Ferlium supports matching on sum types using constructor patterns. These patterns match on specific alternatives and bind their payload to variables:

match Some(10) {
    Some(x) => x + 1,
    None => 0,
}

You can match all three forms of alternatives: nullary (unit-like), tuple-style, and record-style:

enum Action {
    Quit,
    Jump(float),
    Move { x: float, y: float },
}

fn f(a) {
    match a {
        Quit => 0.0,
        Jump(h) => h,
        Move { y, x } => x - y,
    }
}

f(Action::Move { x: 30.0, y: 40.0 })

In record-style patterns, .. can be used to ignore remaining fields.

match (Some { x: 1, y: 2, z: 3 }) {
    Some { x, .. } => x + 10,
}

.. must appear as the last entry in the record pattern. If it appears earlier, compilation fails.

Typing rules

All arms must have compatible result types

A match expression has a single result type, so all arms must produce compatible values.

let n = match false {
    true => 1,
    _ => 2,
};

n

If arm results are incompatible, type checking fails.

Binding types come from the matched value

For sum types, bindings introduced by a pattern take their types from the matched constructor’s payload.

let v: None | Some(int) = Some(1);

match v {
    Some(x) => x + 1,
    None => 0,
}

Here x is inferred from Some’s payload type.

A mismatch between the matched value and pattern constructors is rejected:

let v: None | Some(int) = Some(0);

match v {
    None => 0,
}

This fails because v has type None | Some(int), but the match only constrains it to None.

Exhaustiveness and defaults

Literal matches

Without _, literal matching is considered exhaustive only when Ferlium can enumerate all possible values of the matched type. In practice, this works for finite enumerable domains (for example bool, and tuples or records built from enumerable field types).

match true {
    true => 1,
    false => 0,
}

For non-enumerable domains (for example int), omitting _ is rejected.

let a = 0;
match a {
    0 => 1,
}

Sum type matches

For constructor patterns, omitting _ means that the listed alternatives must cover the matched sum type completely. This code is correct:

let v: None | Some(int) = Some(0);

match v {
    Some(x) => x,
    None => -1
}

But an incomplete set is rejected:

let v: None | Some(int) = Some(0);

match v {
    Some(x) => x,
}

Use all alternatives (or a default arm _) to cover the full sum type.

Current limitations

  • A match arm cannot consist solely of a variable pattern. Use a literal, a constructor pattern, or _ as the default arm.
  • Destructuring plain tuples or records into variable bindings (e.g. (x, y)) is not yet supported; tuple and record patterns currently only work with literal and wildcard sub-patterns.
  • .. is not supported in tuple-style constructor patterns.
  • In record-style constructor patterns, .. is allowed only at the end.
  • Pattern guards (extra if conditions on patterns) are not available.

What comes next

The next chapter treats functions as first-class values, showing how to pass them as arguments, return them from functions, and create closures.

Functions as Values

In earlier chapters, you used named functions and relied on inference to keep code concise. In this chapter, we treat functions as ordinary values: you can create them inline, pass them around, return them, and close over surrounding data.

Anonymous functions

An anonymous function (lambda) is written with pipes around its parameters:

|x| x + 1

You can bind it to a name and call it like any other function value:

let inc = |x| x + 1;
inc(41)

Lambdas can have zero, one, or many parameters:

let one = || 1;
let id = |x| x;
let add = |x, y| x + y;

(one(), id(10), add(2, 3))

Like other expressions, lambda bodies can be single expressions or blocks.

Functions are values

A function value can be stored anywhere a value can be stored: in bindings, tuples, arrays, and match arms.

let ops = [|x| x + 1, |x| x * x];
ops[0](ops[1](3))
let transform = match 0 {
    0 => |x| x * 2,
    _ => |x| x * x,
};

transform(3)

This is the key shift: functions are first-class values. They are not special syntax that only works at declaration sites.

Passing functions as arguments

You can pass a function value to another function:

fn apply_twice(f, x) {
    f(f(x))
}

apply_twice(|n| n + 1, 5)

This pattern is common when working with collections and iterators:

array_map([1, 2, 3], |x| x + 10)

The receiving function constrains what the passed function must accept and return.

Returning functions from functions

A function can also produce and return another function:

fn make_adder(base) {
    |x| x + base
}

let add10 = make_adder(10);
add10(5)

This is useful when you want to configure behavior once and apply it later.

Closures capture surrounding values

When a lambda refers to names from an outer scope, it forms a closure. In Ferlium, captures are by value. This means the closure receives its own copy of the captured values at the time it is created.

let a = 3.3;
let f = || a;
f()

Here f stores its own captured copy of a.

Capture is independent from later outer changes

Changing the outer variable after creating the closure does not change the captured value:

let mut a = 1;
let f = || a;
a = 2;
f()

This evaluates to 1.

Mutating inside a closure does not mutate the outer binding

Because capture is by value, mutating a captured variable inside the closure updates the closure’s private copy, not the outer binding:

let mut a = 1;
let f = || { a = 2; a };
f();
a

This evaluates to 1 for the outer a.

The same idea applies to mutable structures such as arrays: the closure captures its own value, not a shared outer cell.

Type inference for lambdas

Lambda parameters and results are inferred from how the lambda is used.

let add1 = |x| x + 1;
add1(41)

Here, the type of add1 is inferred to be a function that takes an int and returns an int, because of how it is called. Its type would be different if it were called with a float:

let add1 = |x| x + 1;
add1(3.14)

Lambdas bound with let are not generalized

A let-bound lambda is inferred once and then retains that single inferred type within its scope.

let id = |x| x;
id(1);
id(true)

This fails because id is not re-generalized per call.

If you need behavior that works uniformly across many types, use a named function definition, as discussed in Functions and Type Inference.

Summary

Anonymous functions let you write behavior inline. Because functions are values, you can store, pass, and return them naturally. Closures make lambdas practical by capturing surrounding values, and in Ferlium those captures are by value, which keeps mutation behavior predictable. Inference keeps lambda syntax light, while let-bound lambdas stay at a single inferred type per scope.

What comes next

The next chapter explores type abstraction, showing how Ferlium infers polymorphic types, tracks trait constraints, and applies sensible defaults.

Type Abstraction

Ferlium provides powerful type abstraction without requiring heavy type syntax. In practice, you write normal code, and the compiler infers polymorphic types, tracks trait constraints from operations, and applies sensible defaults when some types remain ambiguous.

Recap: inferred polymorphism

From the user perspective, polymorphism in Ferlium is mostly automatic. When a function body does not force a specific concrete type, Ferlium keeps the function general.

fn id(x) { x }

(id(1), id(true), id("hi"))

This works because Ferlium infers one generalized type for id and instantiates it at each call site. Inference is whole-module, so functions are inferred together and can constrain each other.

Traits: shared behavior across types

A trait describes behavior that multiple types can support. By behavior, we mean a set of functions that can be performed on values of that type. Operations and standard functions rely on traits rather than concrete types. For example:

  • numeric operations rely on numeric behavior (Num trait)
  • ordering operations rely on ordered behavior (Ord trait)

Another useful way to think of a trait is as a relation over types.

  • Many traits relate one main type to behavior (for example numeric and ordering behavior).
  • Some traits relate multiple types at once.
  • Some traits also expose output type slots (often called associated types).

Traits are also called type classes in some languages, and they are a powerful way to achieve polymorphism and code reuse without inheritance.

Traits relating multiple types

A good example of trait relating multiple types is Cast, which relates a source type and a target type. You use it with explicit as casts:

let i: int = 5;
let f = i as float;  // Cast(From = int, To = float)
let j = 5.3 as int;  // Cast(From = float, To = int)

(f, j)

Associated types

Traits can also carry associated type information. In the standard library, iterator and sequence traits use this idea:

  • Iterator has an associated Item type (the element type produced by next).
  • Seq links a sequence type to both its element type and its iterator type.

This helps explain annotations: when the IDE shows inferred constraints, you may see that some types are not independent, but connected through trait relations.

Constraints: how operations shape types

When you use an operation, you introduce a constraint. A constraint says: “this type must implement a given trait”.

Examples:

  • x + y adds numeric constraints, meaning that x and y must be of the same type and implement the Num trait.
  • x < y adds ordering constraints, meaning that x and y must be of the same type and implement the Ord trait.
  • x as float adds a cast constraint, meaning the source type must be castable to float.

So this function gets a numeric constraint from +:

fn twice(x) { x + x }

and this one gets an ordering constraint from <:

fn min_like(a, b) {
    if a < b { a } else { b }
}

In the IDE (including the playground), inferred type information and constraints are shown as inline annotations, which helps explain why a function is accepted or rejected.

For iterator-style code, these annotations are especially useful because associated types are inferred for you:

let mut it = iter([1, 2, 3]);
next(it)

Here, the element type (Item) is inferred as int, and the return type of next follows as None | Some(int).

Implementing existing traits

At the moment, traits are defined in the standard library, and user code can implement those existing traits for user-defined types.

struct S;

impl Serialize {
    fn serialize(x: S) {
        None
    }
}

Another example:

struct S;

impl Deserialize {
    fn deserialize(v) {
        S
    }
}

When writing an impl, the method signatures and behavior must match the requirements of the trait.

Partial type annotations with _

You can annotate only the parts you care about and leave the rest to inference using _, which acts as a type hole.

fn id_array(x: [_]) { x }
fn pair(v) -> (_, _) { v }
fn keep_shape(x) -> [_] { x }

This is useful when you want to constrain structure (for example, “array of something” or “pair of something”) without naming every type explicitly.

You can also use _ in local annotations:

fn f(x) {
    let a: [_] = x;
    a
}

Defaulting of ambiguous types

Sometimes inference leaves a type variable unconstrained enough that multiple choices would fit. Ferlium applies defaulting rules so code remains ergonomic.

Numeric defaulting

If an unconstrained numeric type is only known to satisfy the Num trait, Ferlium defaults it toint.

let n = 0;
n

Here n defaults to int unless later context requires a different numeric type. If context does require one, that context wins:

let f: float = 1;
f

Open sum defaulting

For open sum-type information that remains unconstrained, Ferlium defaults to a closed minimal sum type: the smallest set of constructors required by the code.

let v = Some("text");
v

In this situation, Ferlium can close the type to the minimal constructor set needed by the expression, instead of leaving it indefinitely open.

Known limitations

Currently, numeric and open sum defaulting do not combine well. When both need to apply to the same expression, compilation can fail. This is a known limitation.

What you cannot write explicitly yet

Today, type abstraction is largely inference-driven. In particular:

  • you cannot write explicit generic parameter lists on functions
  • you cannot define new traits in user code yet
  • you cannot write explicit user-level trait constraint clauses for functions

You still get polymorphism and trait-based behavior through inference and standard-library traits.

Looking ahead

As Ferlium evolves, explicit generic syntax will be added on top of the current inference-first model. For now, the intended workflow is: write ordinary code, let inference produce the general type, and use lightweight annotations (including _) only when they improve clarity.

What comes next

The next chapter introduces effects, describing how functions can interact with their environment beyond pure computation.

Effects

Effects describe how a function can interact with its environment beyond pure value computation. In Ferlium, effects are inferred automatically and make a function’s behavior explicit: whether it may read environment state, write environment state, or fail at runtime.

What effects mean in Ferlium

A function’s effect set is part of its function type.

  • No effects means the function is pure (it only computes from its inputs).
  • read means the function may read from environment-provided state.
  • write means the function may modify environment-provided state.
  • fallible means the function may fail at runtime (for example, division by zero or panic).

Effects describe environment interaction and failure behavior; they do not describe mutation of Ferlium-owned state.

Effect kinds available today

The implemented primitive effects are:

  • read
  • write
  • fallible

These names are visible in inferred function signatures and diagnostics.

Where you can see effects today

Ferlium shows effects in function type displays when the effect set is non-empty:

  • Pure function: (int) -> int
  • Effectful function: (int, int) -> int ! fallible
  • Multiple effects: () -> () ! read, write

This ! … suffix appears in inferred function signatures and IDE annotations. Effect-related compilation errors also report effect sets (for example, incompatible effect dependencies).

Ferlium currently does not provide user syntax to manually write effect constraints; effects are inferred from code.

Effects are inferred and propagate through calls

You do not declare effects manually. Ferlium infers them from what a function does.

If a function calls another function, the caller inherits the callee’s effects. This propagation is transitive: effects flow through chains of calls.

Examples

Pure function

fn add1(x: int) {
    x + 1
}

Inferred signature shape: (int) -> int (no effect suffix).

Function that reads from the environment

fn current_counter() {
    @props::my_scope.my_var
}

Inferred signature shape: () -> int ! read.

This uses environment property access. Reading such a property contributes a read effect.

Function that writes to the environment

fn reset_counter() {
    @props::my_scope.my_var = 0
}

Inferred signature shape: () -> () ! write.

Writing environment-backed state contributes a write effect.

Fallible function and fallibility propagation

fn quotient(a, b) {
    idiv(a, b)
}

fn quotient_plus_one(a, b) {
    quotient(a, b) + 1
}

idiv is fallible (for example when b == 0), so any function that calls it is also inferred as fallible:

  • quotient is inferred as fallible.
  • quotient_plus_one is also inferred as fallible because it calls quotient.

Signature shapes:

  • (int, int) -> int ! fallible
  • (int, int) -> int ! fallible

Why effects are useful

Effects make behavior easier to reason about: you can quickly see whether a function is pure, interacts with external state, or can fail.

They also support optimization, because pure and less-effectful code can be analyzed and transformed more aggressively than code with broader effects.

Future direction: async

Ferlium’s current effect system is a base for future execution models. In particular, async can be layered on top of this effect tracking later, without changing the core idea that effects are inferred and propagated.

What comes next

The next chapter covers evaluation and mutable state, explaining how Ferlium handles mutation through mutable references and the rules that keep it safe.

Evaluation and Mutable State

This chapter builds the runtime mental model for mutation in Ferlium. The key idea is simple: arguments are passed by value unless a function requires mutable access, in which case a mutable reference is passed and checked for safety.

Values vs mutable references

Ferlium calls can behave in two different ways, depending on the callee’s parameter mutability:

  • By value: the callee receives a value and cannot update the caller’s binding through that parameter.
  • By mutable reference: the callee receives mutable access to the caller’s place, and assignments in the callee update the caller’s value.

A practical way to read this is:

  • if the function only reads its parameter, treat it as value passing;
  • if the function writes to its parameter, the call requires mutable passing.
fn plus_one(x) {
    x + 1
}

fn set_1(a) {
    a = 1
}

let mut n = 0;
let r = plus_one(n);
set_1(n);

(r, n)

The function plus_one computes a new value and does not mutate n. On the contrary, because set_1 assigns to its parameter, the call requires mutable passing.

Creating and using a mutable reference

Short reminder: only mutable bindings can be updated. For call passing, this means mutable references are created from mutable bindings when needed.

In Ferlium today, you do not write a separate expression like &mut x at the call site. Instead:

  • the callee’s parameter mutability (inferred or annotated as &mut in function types) determines that mutable passing is required;
  • passing a mutable binding supplies that mutable reference;
  • passing an immutable binding is rejected.
let mut a = [1];
array_append(a, 2);
a
let a = [1];
array_append(a, 2);
a

The first program works; the second is rejected because a is not mutable.

You can also see mutable references in function type syntax. &mut is supported in function argument positions:

fn push_zero(xs: &mut [int]) {
    array_append(xs, 0)
}

let mut data = [1, 2];
push_zero(data);
data

Passing mutable references through helper functions

A mutable reference can be forwarded through multiple calls. This is what makes in-place helper-function design work, including recursive call chains.

fn set_1(a) {
    a = 1
}

fn call_set_1(a) {
    set_1(a)
}

let mut a = 0;
call_set_1(a);
a

call_set_1 does not copy a; it forwards the same mutable access, so set_1 still updates the original binding.

This forwarding model is exactly what recursive in-place algorithms rely on: each recursive step receives mutable access to the same underlying structure (or a non-overlapping part of it).

Quicksort example

Here is the canonical in-place quicksort algorithm, implemented in Ferlium using mutable references:

fn swap(a, i, j) {
    let temp = a[i];
    a[i] = a[j];
    a[j] = temp
}

fn quicksort(a, lo, hi) {
    if lo >= hi or lo < 0 {
        ()
    } else {
        let p = partition(a, lo, hi);
        quicksort(a, lo, p - 1);
        quicksort(a, p + 1, hi)
    }
}

fn partition(a, lo, hi) {
    let pivot = a[hi];
    let mut i = lo;

    for j in lo..hi {
        if a[j] < pivot {
            swap(a, i, j);
            i = i + 1
        }
    };

    swap(a, i, hi);

    i
}

let mut a = [5, 4, 11, 3, 2, 1, 0, 7];
quicksort(a, 0, array_len(a) - 1);
a

How to read this operationally:

  • a is mutable in the caller (let mut a = ...).
  • quicksort and partition receive mutable access to that array through calls.
  • swap performs writes (a[i] = ...) that are visible to all callers in the current call chain.
  • No intermediate copies of the array are created; all updates apply to the original binding.

Aliasing rules enforced today

Ferlium enforces rules to avoid confusing mutable aliasing:

  • mutable passing requires mutable places (not immutable bindings);
  • overlapping mutable paths in the same call are rejected.

In other words, a single call cannot pass two mutable arguments that point to the same storage region. This is checked by the compiler and reported as an overlap error. For example, passing the same array twice as two mutable parameters to a function is rejected.

Mutation is independent of effects

The effect system from the previous chapter tracks environment interaction and fallibility (read, write, fallible).

Mutable updates to Ferlium-owned values via mutable references are a separate mechanism from environment effects. A function can be pure with respect to environment effects and still perform in-place mutation on values passed to it.