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”boolwith valuestrueandfalseintrepresenting whole numbersfloatrepresenting floating-point numbersstringrepresenting 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.1isfloat,"hi"isstring). - 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
ifexpressions, 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 —
structandenum— 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
ifconditions 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 (
Numtrait) - ordering operations rely on ordered behavior (
Ordtrait)
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:
Iteratorhas an associatedItemtype (the element type produced bynext).Seqlinks 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 + yadds numeric constraints, meaning thatxandymust be of the same type and implement theNumtrait.x < yadds ordering constraints, meaning thatxandymust be of the same type and implement theOrdtrait.x as floatadds a cast constraint, meaning the source type must be castable tofloat.
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).
readmeans the function may read from environment-provided state.writemeans the function may modify environment-provided state.falliblemeans the function may fail at runtime (for example, division by zero orpanic).
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:
readwritefallible
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:
quotientis inferred as fallible.quotient_plus_oneis also inferred as fallible because it callsquotient.
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
&mutin 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:
ais mutable in the caller (let mut a = ...).quicksortandpartitionreceive mutable access to that array through calls.swapperforms 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.