Introduction
Ferlium is a small, statically-typed programming language designed to be embedded in larger applications. The host application loads Ferlium code, type-checks and compiles it, and runs it in a controlled environment. The language itself has no built-in I/O, no filesystem access, and no network, so every effect a script can have on the outside world goes through a function the host has explicitly registered. Programs can range from a single expression to several modules that reference one another in an acyclic dependency graph. The language combines Hindley–Milner style type inference with a syntax inspired by Rust and other C-style languages, aiming to feel approachable to readers familiar with JavaScript or Python. It supports pattern matching and uses a simple mutable value semantics in the spirit of Rust ownership.
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, programs and modules, 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 */
Documentation comments start with ///.
They document the item or field that follows them:
/// Returns the square of a number.
fn square(x: int) {
x * x
}
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.
Raw identifiers
Some words are reserved by the language, such as type, pub, and fn.
When you need to use one of these words as a user-defined name, write it as a raw identifier with r#.
let r#type = "message";
r#type
The r# prefix is only source syntax.
The logical name is still type, so raw identifiers are mainly useful for interoperating with external data or APIs whose field names collide with Ferlium keywords.
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 function parameters
You can write mut before a function parameter name to get a mutable local binding for it inside the function body:
fn add_one(mut n) {
n += 1;
n
}
add_one(10)
This is equivalent to writing:
fn add_one(n) {
let mut n = n;
n += 1;
n
}
add_one(10)
The mut notation is purely a convenience: the function signature is unchanged, and the caller’s value is never affected.
You can mix mut and non-mut parameters freely:
fn add_n(mut x, n) {
x += n;
x
}
add_n(5, 3)
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.
Destructuring bindings
let can destructure product values instead of binding the whole value to a single name.
Tuples
Tuple destructuring binds each element by position:
let point = (10, 20);
let (x, y) = point;
(x, y)
Use _ to ignore elements you do not need:
let triple = (1, 2, 3);
let (first, _, third) = triple;
(first, third)
Patterns can be nested:
let ((feet, inches), (x, y)) = ((3, 10), (4, 5));
feet + inches + x + y
Records
Anonymous records can be destructured by field name:
let person = { name: "Ada", age: 36 };
let { age, name } = person;
f"{name} is {age}"
Field order does not matter.
You can rename fields while destructuring:
let p = { x: 3, y: 4 };
let { x: horizontal, y: vertical } = p;
horizontal + vertical
Named structs
Tuple structs use tuple-style destructuring:
struct Color(int, int, int)
let c = Color(10, 20, 30);
let Color(r, g, b) = c;
r + g + b
Record structs use record-style destructuring:
struct Point { x: int, y: int }
let p = Point { x: 3, y: 4 };
let Point { x, y } = p;
x + y
For named tuple and record structs, .. ignores the remaining elements or fields:
struct Point3 { x: int, y: int, z: int }
let p = Point3 { x: 1, y: 2, z: 3 };
let Point3 { x, .. } = p;
x
Mutability in destructuring
Mutability applies to each binding individually:
let (mut a, b) = (1, 2);
a = 10;
a + b
This is different from let mut value = ..., which makes the whole binding mutable.
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. This extends to recursive data: a function that recurses over a variant infers a recursive structural type for it, see Recursive types.
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.
Pipeline calls with |>
Ferlium provides a pipeline operator, |>, as a lightweight way to write function application left to right.
fn inc(x) { x + 1 }
fn add(x, y) { x + y }
41 |> inc() |> add(5)
This is equivalent to:
fn inc(x) { x + 1 }
fn add(x, y) { x + y }
add(inc(41), 5)
In general:
value |> f()meansf(value)value |> f(a, b)meansf(value, a, b)
This is especially useful later when chaining sequence operations.
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,
}
Early return
A function normally returns the value of its body block. Use return when you need to leave the function early:
fn clamp_non_negative(x: int) -> int {
if x < 0 { return 0 };
x
}
clamp_non_negative(-3)
The returned expression must have the function’s return type. A return exits the current function or lambda, even when it appears inside a nested block, if, or match arm.
let f = |x| {
if x { return 1 };
2
};
f(true)
Loop expressions
Use loop when the stopping condition lives inside the body.
A break exits the loop, and continue skips to the next iteration.
let mut n = 0;
loop {
n += 1;
if n < 3 { continue };
break n
}
A loop is an expression.
Its result type comes from the values passed to break; a bare break is the same as break ().
let answer = loop {
break 42
};
If a loop has no reachable break, its type is never.
Loop labels
Labels let break and continue target an outer loop directly:
let mut outer = 0;
let mut inner = 0;
'outer: loop {
outer += 1;
if outer == 3 { break inner };
loop {
inner += 1;
continue 'outer
}
}
The label is written before the target loop, followed by :, and reused after break or continue.
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];
Use indexed assignment to update an element through a mutable array binding:
let mut xs = [10, 20, 30];
xs[1] = 25;
xs
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
Parenthesize computed bounds:
let start = 0;
let r = start..(start + 3);
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] { /* ... */ };
Destructuring in for loops
The loop variable can destructure tuples and records:
for (i, name) in [(0, "zero"), (1, "one"), (2, "two")] {
let entry = f"{i} = {name}";
};
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 += i;
};
sum
Collecting values works the same way:
let mut out: [int] = [];
for i in 2..5 {
array_append(out, i);
};
out
For more on in-place array and string construction, see Sequence Processing.
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 += 1;
};
count
For unconditional loops and labeled break or continue, see Control Flow and Pattern Matching.
What comes next
The next chapter introduces user-defined types, so you can give names to your own product and sum types instead of relying only on arrays, tuples, and records.
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 can also be generic:
type Pair<T> = (T, T);
type Mapping<K, V> = [(K, V)];
Generic aliases are expanded at each use site with the provided type arguments:
type Pair<T> = (T, T);
type Mapping<K, V> = [(K, V)];
let p: Pair<int> = (1, 2);
let m: Mapping<string, int> = [("a", 1)];
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
If a field name collides with a keyword, use a raw identifier:
let event = { r#type: "click", r#pub: true };
event.r#type
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);
The | syntax separates variant alternatives, not arbitrary value types.
Each alternative needs its own tag name:
// Wrong: bool and int are type names, not variant tags.
type Value = bool | int;
// Right: Bool and Int are tags carrying bool and int payloads.
type Value = Bool(bool) | Int(int);
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
}
A bare variant tag can also appear directly in a type annotation. If the name does not resolve to a generic parameter, type alias, struct, or enum, Ferlium treats it as a structural unit variant type:
let signal: Ready = Ready;
This means that signal has a sum type containing the unit variant Ready.
It does not require a prior enum declaration.
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 {
/// Display name.
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,
/// Text to display.
Write(
/// Message contents.
string,
),
Move {
/// Horizontal position.
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 };
The same qualified variant names can be used in match patterns:
enum Message {
Quit,
Write(string),
Move { x: int, y: int }
}
let message = Message::Move { x: 10, y: 20 };
match message {
Message::Quit => 0,
Message::Write(text) => len(text),
Message::Move { x, y } => x + y,
}
Generic named types
Named types can also be generic.
struct Box<T>(T)
enum MaybeValue<T> {
Absent,
Present(T),
}
This lets one named type definition work for many concrete types while keeping nominal identity.
Ferlium also supports explicit where clauses on generic named types, but the full syntax and meaning of explicit generic parameters and constraints are introduced later in Explicit Types.
Recursive types
Type aliases and named types can refer to themselves, directly or indirectly, when the recursion goes through a sum type. This is useful for lists, trees, and other recursive data structures.
type List<T> = Nil | Cons(T, List<T>);
Recursive enums work the same way:
enum Tree<T> {
Leaf,
Node(T, Tree<T>, Tree<T>),
}
The recursion may also pass through another type:
enum Tree<T> {
Leaf,
Node(NodeData<T>),
}
struct NodeData<T> {
value: T,
left: Tree<T>,
right: Tree<T>,
}
Recursive product types are rejected unless the cycle goes through a sum type with a terminating branch.
For example, struct Node { next: Node } has no finite value, while Tree works because Leaf stops the recursion.
For generic recursive types, recursive references must pass the type parameters through unchanged.
For example, Tree<T> is supported, but Tree<(T, T)> in the recursive position is rejected.
Recursive structural types are also inferred from unannotated recursive function bodies, under the same rules: the recursion must go through a variant with a terminating branch.
fn size(t) {
match t {
Leaf(x) => 1,
Node(l, r) => size(l) + size(r),
}
}
Here the type of t is inferred as the structural recursive variant Leaf (A) | Node (Self, Self), where Self stands for the type itself.
A value of a structural alias with the same shape, such as type Tree<T> = Leaf(T) | Node(Tree<T>, Tree<T>), can be passed to size directly.
This extends to mutually recursive structures, whether the values are consumed in matches or only built with constructors.
Destructuring structured values
Tuples, records, and nominal product types can also be destructured in let bindings.
This includes tuple structs, record structs, nested destructuring, per-binding mut, _ to ignore tuple elements, and .. to ignore the remaining fields of named structs.
See Bindings, Scope, and Mutability for the full syntax and examples.
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.
In IDE annotations, this relationship is shown compactly as T ⇝ U, read as “T is represented as U”.
For example, a constraint like T ⇝ U, U: { x: int, … } means that values of type T expose a representation U on which structural operations such as field access are checked.
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 })
For named enums, patterns may also qualify the variant with the enum name. This can help type inference when the matched value has no explicit type annotation:
enum Action {
Quit,
Jump(float),
Move { x: float, y: float },
}
let action = Action::Move { x: 30.0, y: 40.0 };
match action {
Action::Quit => 0.0,
Action::Jump(h) => h,
Action::Move { y, x } => x + y,
}
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.
Named functions and trait methods are values
Named functions can also be used directly as values. This includes functions provided by traits.
fn apply2(f, left, right) {
f(left, right)
}
apply2(add, 20, 22)
Here add is the method from the standard-library Num trait.
The call to apply2 constrains the argument types so Ferlium can choose the right Num implementation.
You can also bind a trait method to a local name before calling it:
fn double_with_add(value) {
let my_add = add;
my_add(value, value)
}
double_with_add(21)
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:
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.
A common standard trait: Value
One important standard trait is Value.
It describes types that support:
- semantic equality
- conversion to string
- hashing
In everyday code, this trait is what supports operations and functions such as:
left == rightto_string(value)hash(value, state)
Many built-in types implement Value, including bool, int, float, string, and arrays whose elements also implement Value.
Structured data also participates naturally:
- tuples and records support
Valuestructurally structandenumtypes deriveValuefrom their fields or variants when their components do
For product types, hashing follows the type-defined field order. For sum types, hashing includes the active variant together with its payload.
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.- Some traits also expose associated effects, such as the effect of advancing an iterator.
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 a function from a trait, 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).
Defining your own traits
User code can also define traits. A simple trait with one main input type looks like this:
trait Double<Self> {
fn double(value: Self) -> Self;
}
Traits may also expose output type slots and carry where clauses that specify constraints:
trait Project<Self |-> Output>
where
Self: Value
{
fn project(value: Self) -> Output;
}
This reads as a relation over types:
Doublerelates one input type,SelfProjectrelates one input type,Self, to one output type,Output
The method signatures form the contract that implementations must satisfy.
Implementing traits
Once a trait exists, you can implement it for suitable types. For example, using the trait defined above:
trait Double<Self> {
fn double(value: Self) -> Self;
}
impl Double for int {
fn double(value: int) -> int {
value * 2
}
}
double(21)
You can also implement standard-library traits for your own types:
struct Wrapper(int)
impl Ord for Wrapper {
fn cmp(left: Wrapper, right: Wrapper) {
cmp(left.0, right.0)
}
}
When writing an impl, the method signatures and behavior must match the requirements of the trait.
Impls can also be generic, and Ferlium supports explicit trait input and output bindings in impl headers.
A later chapter, Trait Implementations and Coherence, covers this in detail.
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.
Looking ahead
Ferlium continues to evolve by layering explicit syntax on top of the current inference-first model.
The intended workflow remains: write ordinary code, let inference produce the general type, and use explicit binders, where clauses, and lightweight annotations (including _) when they improve clarity.
What comes next
The next chapter introduces Ferlium’s explicit type syntax, including explicit annotations, generic parameter lists, and where clauses.
Explicit Types
Ferlium lets you write types explicitly when doing so improves clarity or constrains inference.
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
}
You can annotate arbitrary expressions using the (expr: T) syntax:
(1 + 1: float)
Functions
Generic parameters on functions
You can also make a function’s generic parameters explicit. This is useful when you want the surface syntax to show that a function is polymorphic, or when you want annotations inside the function body to refer to those generic parameters by name.
fn keep<T>(value: T) -> T {
let same_value: T = value;
same_value
}
(keep(1), keep(true))
This does not turn off inference. The body is still type-checked as usual, but the explicit parameter list fixes the names and scope of the generic parameters used by the signature and by type annotations in the body.
Function generics compose naturally with inference for the rest of the signature. You may annotate as much or as little as you want.
Effect parameters can be made explicit in the same list after !:
fn apply<T ! E>(value: T, f: (T) -> T ! E) -> T {
f(value)
}
Function-level where clauses
Functions may also carry an explicit where clause.
This makes trait requirements part of the function’s declared interface rather than leaving them entirely implicit.
fn keep_ord<T>(value: T) -> T
where
T: Ord
{
value
}
This is especially useful when a function’s behavior depends on trait-supported operations and you want that dependency to be visible in the source. The body is still checked against the declared constraints, so an invalid call is rejected just as it would be for an inferred constraint.
Nominal types
Named types can themselves be generic.
This lets you define one struct or enum shape that works for many concrete types, while still keeping nominal identity.
Generic struct
Generic parameters are written in angle brackets after the type name:
struct Box<T>(T)
struct Pair<A, B> {
first: A,
second: B,
}
You use a generic named type by applying concrete type arguments:
let a = Box(1); // produces a Box<int>
let b = Pair { first: 1, second: "hi" }; // produces a Pair<int, string>
Generic enum
Enums can be generic as well:
enum MaybeValue<T> {
Absent,
Present(T),
}
Construction and matching work the same way as for non-generic enums:
enum MaybeValue<T> {
Absent,
Present(T),
}
let value: MaybeValue<int> = MaybeValue::Present(41);
match value {
Present(x) => x,
Absent => 0,
}
where clauses in nominal types
Generic named types can have where clauses.
These constrain which type arguments are allowed.
struct TransformIter<I, T, O>
where
I: Iterator<Item = T ! NextEffect = ()>
{
iterator: I,
mapper: (T) -> O,
}
This says that TransformIter<I, T, O> only makes sense when I is an iterator producing T without any effect.
The where clause can mention:
- trait constraints on the generic parameters
- associated type bindings such as
Item = T - associated effect bindings such as
NextEffect = () - multi-parameter trait constraints, as described later in the type abstraction chapters
What can be explicit today
Ferlium still has an inference-first design, but several parts of type abstraction can now be written explicitly:
- you can write explicit generic parameter lists on functions
- you can write explicit generic effect parameters
- you can write function-level
whereclauses - you can define generic
structandenumtypes - you can add
whereclauses to generic type definitions - you can define traits in user code
- you can write generic
implblocks for traits - you can add
whereclauses to thoseimplblocks - you can write explicit trait input and output bindings in impl headers
Some limitations remain:
- you cannot write per-method generic parameter lists or method-local
whereclauses inside trait impls
You still get polymorphism and trait-based behavior primarily through inference, and explicit syntax mainly serves to document or constrain that inferred structure.
What comes next
The next chapter introduces trait implementations and coherence, describing how you can implement traits for your types, and how Ferlium ensures that trait resolution remains predictable across module boundaries.
Trait Implementations and Coherence
Ferlium lets user code implement both standard-library traits and user-defined traits.
This chapter covers the surface syntax for impl, generic impls, explicit trait bindings, and the rules that keep trait resolution unambiguous.
Basic impl syntax
For a simple single-input trait with no explicit output types, the syntax is:
struct Counter(int)
impl SizedSeq for Counter {
fn len(counter: Counter) {
counter.0
}
}
This is the compact form of an impl header.
It works well for traits that conceptually operate on one main type.
You can think of it as sugar for an explicit binding such as impl SizedSeq for <Self = Counter> { ... }.
Generic impls
Impls can introduce their own generic parameters with Rust-like binder syntax:
struct Bag<T>([T])
impl<T> SizedSeq for Bag<T> {
fn len(bag: Bag<T>) {
len(bag.0)
}
}
Here the impl says: for every T, Bag<T> is a sized sequence.
The same binder syntax also works for more involved generic impls, including iterator-like types.
The next sections show those richer impl headers.
Impl-level where clauses
Impls may also carry their own where clause.
This is useful when an implementation only applies under extra trait assumptions:
struct Wrapper<T>(T)
impl<T> SizedSeq for Wrapper<T>
where
T: SizedSeq
{
fn len(wrapper: Wrapper<T>) {
len(wrapper.0)
}
}
Here, Wrapper<T> is a sized sequence whenever its inner value is.
The extra constraints become part of the impl itself.
They participate in trait selection and in coherence checking, just like the types named in the impl header.
Explicit bindings
Some traits relate more than one input type, or also expose output slots. In those cases, an impl header can spell the bindings explicitly.
Multi-input traits
For multi-parameter traits such as Cast, named bindings are usually the clearest choice:
struct Wrapper<T>(T)
impl<T> Cast for <From = T, To = Wrapper<T>> {
fn cast(value: T) -> Wrapper<T> {
Wrapper(value)
}
}
Ferlium also accepts positional input bindings such as impl<T> Cast for <T, Wrapper<T>> { ... }.
Named bindings are usually easier to read, and they line up naturally with traits that also have output slots.
Traits with output types and effects
Traits such as Iterator have output slots.
You may write them explicitly:
struct TransformIter<I, T, O ! MapperEffect>
where
I: Iterator<Item = T ! NextEffect = ()>
{
iterator: I,
mapper: (T) -> O ! MapperEffect,
}
impl<I, T, O ! MapperEffect> Iterator for <Self = TransformIter<I, T, O ! MapperEffect> |-> Item = O ! NextEffect = MapperEffect> {
fn next(it: &mut TransformIter<I, T, O ! MapperEffect>) -> Option<O> {
match next(it.iterator) {
Some(value) => Some(it.mapper(value)),
None => None,
}
}
}
Writing output bindings is optional.
If you omit them, Ferlium infers them from the method signatures.
If you write them explicitly, they must agree with the inferred ones.
Effect outputs, such as NextEffect, work the same way for traits that expose them.
Associated constants
Traits may also declare associated constants. An associated constant is a named literal value selected through trait resolution:
trait HasConst<Self> {
const C: Self;
}
impl HasConst for int {
const C = 7;
}
Associated constants are accessed through the trait name:
trait HasConst<Self> {
const C: Self;
}
impl HasConst for int {
const C = 7;
}
HasConst::C
If the trait has an inferred input type, Ferlium infers it from context. You can also spell it explicitly:
trait HasConst<Self> {
const C: Self;
}
impl HasConst for int {
const C = 7;
}
HasConst::<int>::C
The :: before the explicit type arguments keeps this syntax unambiguous with the < comparison operator.
Coherence
Ferlium uses a strict coherence rule for trait implementations:
- for any given trait application, there must be at most one applicable impl
- overlapping impls are rejected
This keeps trait resolution predictable. The compiler never has to choose arbitrarily between two equally valid impls.
For example, this is rejected because both impls target the same trait and type:
struct Wrapper(int)
impl Serialize for Wrapper {
fn serialize(value: Wrapper) {
None
}
}
impl Serialize for Wrapper {
fn serialize(value: Wrapper) {
None
}
}
The same rule also rejects overlapping generic impls. For example, if two generic impl declarations could both apply to the same types, Ferlium rejects them.
The orphan rule
Ferlium does not let an arbitrary module implement an existing foreign trait for arbitrary foreign input types. In practice, when you implement an existing trait, at least one input type in the impl must be a local named type that belongs to your module.
This restriction is called the orphan rule. More generally:
- a local trait may be implemented freely
- a foreign trait requires at least one local named input type in the impl
This is allowed, because Counter is local to the current module:
struct Counter(int)
impl SizedSeq for Counter {
fn len(counter: Counter) {
counter.0
}
}
This is rejected, because both the trait and the input type are foreign:
impl SizedSeq for int {
fn len(value: int) {
1
}
}
The orphan rule prevents unrelated modules from attaching competing impls to the same foreign types. Combined with coherence, it keeps trait resolution predictable across module boundaries, which is important for separate compilation.
Current scope
Today, user code can:
- implement standard-library and user-defined traits
- write generic impls
- write impl-level
whereclauses - use explicit trait input and output bindings in impl headers
User code still cannot:
- write per-method generic parameter lists inside traits or impls
- write method-local
whereclauses inside trait impls
What comes next
The next chapter introduces Ferlium’s module system — how items in different modules refer to one another with :: paths, how use declarations bring names into scope, and the role the host application plays in deciding what modules exist.
Modules and Program Structure
So far the book has shown Ferlium code as single, self-contained snippets.
Real programs are usually larger than that, and Ferlium organises them with modules.
A module is the unit of compilation: a named collection of source definitions (functions and types) that the compiler treats as one piece, with its own scope.
Code in one module can refer to items in another using a path like module::name.
This chapter explains how modules look from the language side, how the standard library fits in as a module of its own, and the part you may not be used to: in Ferlium, the host application decides what modules exist, not the language.
Referencing items across modules
Suppose a host has loaded a module called geometry containing:
fn area(r: float) -> float {
3.14 * r * r
}
From another module, you can call it through its path:
geometry::area(2.0)
The :: separator joins module names and item names.
Paths can have any depth — a host may organise its API as physics::collision::detect(...) or engine::input::mouse_position().
Public and module-local definitions
Definitions are module-local by default.
Another module can only refer to a definition if it is marked pub:
pub fn area(r: float) -> float {
3.14 * r * r
}
pub type Point = (float, float);
pub struct Circle {
radius: float,
}
pub trait HasArea<Self> {
fn area(value: Self) -> float;
}
The pub modifier can be used on functions, type aliases, structs, enums, and traits.
Trait implementations do not have their own visibility modifier: an implementation is available across modules only when the trait and the types involved are themselves visible.
Private definitions are still visible inside their own module. This also applies when the host evaluates an expression in the context of an existing module.
use declarations
Repeating long paths becomes noisy.
A use declaration brings a name from another module into the current scope:
use geometry::area;
area(2.0)
You can import several names at once:
use geometry::{area, perimeter};
area(2.0) + perimeter(2.0)
Or import everything a module exposes with the wildcard form:
use geometry::*;
area(2.0)
Grouped imports can also be nested:
use shapes::{
circle::{area, perimeter},
square::side,
};
If two use declarations bring in the same name, or if a use collides with a local definition, the compiler reports an error rather than silently shadowing.
The standard library is a module
You have already been using a module without knowing it.
Functions like map, filter, len, and idiv come from std, which Ferlium pre-imports into every module with an implicit wildcard use std::*;.
You can still write the long form when it helps clarity:
std::len([1, 2, 3])
is exactly the same as:
len([1, 2, 3])
The host decides how modules are organised
This is a key difference from Rust, Python, or JavaScript: Ferlium itself does not look at the file system.
A Ferlium script cannot declare a new module with something like mod foo { ... }, and there is no import "./helpers.fer".
Modules are created by the application that embeds Ferlium, and that application chooses the layout.
Common layouts you will encounter:
- The playground. All the code you type lives in a single module. Cross-module syntax still works for calling into
std, but you do not see other modules unless the playground exposes them. - The REPL. Each entry you type is compiled as its own module. Later entries can refer to earlier ones by name.
- An embedding application. The host might give every user-authored script its own module while exposing host-provided modules for the API — for example, a game engine could expose
engine,audio, andinput, and put each gameplay script the user writes into its own module that uses them.
The same Ferlium code can therefore live in very different module layouts depending on where it runs.
What stays the same is the language-level syntax for paths and use declarations.
Why this design
Putting module organisation in the host’s hands has two practical benefits:
- The host can shape trust boundaries: it decides which modules exist, what they contain, and which scripts may reference which. The language has no ambient I/O, so the host’s choice of exposed modules is also the script’s entire surface area for interacting with the outside world.
- The host can manage incremental recompilation: when a module is changed, every module that depends on it is automatically marked stale and recompiled in dependency order. This makes IDE-style workflows — including the playground itself — feel instant even on large projects.
What modules are not (today)
To set expectations:
- A module is the unit of compilation. The dependency graph between modules must be acyclic: if
acalls intob, thenbcannot call back intoa. - There is no in-script module declaration syntax (
mod foo { ... }is not part of the language). - There is no file-based module discovery from script source (no
import "./helpers.fer"). - There is no separate package distribution format; modules are whatever the host gives you.
- A module name cannot itself be brought into scope as a value (you can
use geometry::area;but notuse geometry;to then writegeometry::area(...)— that form is currently not supported).
These are language-level limits today, not host-level ones: a host can still expose any module layout it wants.
What comes next
The next section of the book covers Ferlium’s standard library — std is the first module you have been using all along, and you can now read paths like std::len fluently.
Sequence Processing
After learning about functions as values and trait-based abstraction, you can use Ferlium’s standard-library sequence combinators more effectively. This chapter covers the main transformation and reduction functions over collections and iterators, and explains when results are built eagerly versus lazily.
Sorting
Sorting comes in two forms:
sort(array)sorts an array in placesorted(array)returns a sorted copy
For example:
let mut xs = [3, 1, 2, 1];
sort(xs);
xs
sorted([3, 1, 2, 1])
Ferlium’s array sort is stable, so equal elements keep their relative order.
When you need a custom ordering, use sort_by or sorted_by:
let mut xs = [(1, "b"), (0, "x"), (1, "a")];
sort_by(xs, |left, right| cmp(left.0, right.0));
xs
sorted_by([(1, "b"), (0, "x"), (1, "a")], |left, right| cmp(left.0, right.0))
Reversing
Arrays also support reversal in two forms:
let mut xs = [1, 2, 3];
reverse(xs);
xs
reversed([1, 2, 3])
reverse mutates an array in place.
reversed returns a reversed copy.
Transforming sequences
Ferlium provides map, filter, and filter_map for both collections and iterators.
As a reminder, the pipeline operator rewrites left-to-right function calls:
value |> f()meansf(value)value |> f(arg)meansf(value, arg)
Collections transform eagerly
When you call these functions on an array, Ferlium rebuilds an array immediately:
[1, 2, 3] |> map(|x| x + 1)
[1, 2, 3] |> filter(|x| x > 1)
[1, 2, 3] |> filter_map(|x| if x > 1 { Some(x * x) } else { None })
This is useful when you want to stay in the collection world and keep working with arrays directly.
Iterators transform lazily
When you call the same functions on an iterator, you get back another iterator adaptor:
let mut it = [1, 2, 3] |> iter() |> map(|x| x + 1);
(next(it), next(it), next(it), next(it))
This evaluates to:
(Some(2), Some(3), Some(4), None)
The same eager/lazy distinction applies to filter and filter_map.
Callback purity
Callbacks passed to map, filter, and filter_map may have effects.
For eager collection calls, those effects happen while building the result.
For lazy iterator adaptors, callback effects happen later, when the iterator is advanced with next.
Collecting results
Use collect() to turn a sequence or iterator into a target collection.
The target collection type must be known from context:
let xs: [_] = 0..3 |> collect();
xs
let ys: [_] = [1, 2, 3] |> iter() |> map(|x| x as float) |> collect();
ys
This is especially useful when you want to switch back from lazy iterator code to an eager collection value.
The next chapter covers the unordered collection targets that collect() can build, including set and map.
You can also give explicit types using the expression annotation syntax. For example, to collect into an array:
([1, 2, 3] |> iter() |> map(|x| x as float) |> collect(): [_])
Combining and slicing sequences
Ferlium includes functions to process sequences, producing iterators.
Pairing with zip
zip pairs elements from two sequences into tuples, stopping at the shorter one:
([0, 1, 2] |> zip(["first", "second", "third"]) |> collect(): [_])
Indexing with enumerate
enumerate pairs each element with its zero-based index:
(["zero", "one", "two"] |> enumerate() |> collect(): [_])
This is useful for iterating over a collection with the index of the elements:
for (i, name) in ["zero", "one", "two"] |> enumerate() {
let entry = f"{i} = {name}";
}
Limiting with take and skip
take(n) keeps only the first n elements; skip(n) drops them:
(0..10 |> take(3) |> collect(): [_])
(0..5 |> skip(2) |> collect(): [_])
If n exceeds the sequence length, take returns all elements and skip returns none.
Concatenating with chain
chain appends a second sequence after the first:
(chain([1, 2], [3, 4, 5]) |> collect(): [_])
(chain(0..3, 3..6) |> collect(): [_])
(chain(["Hello", "world"], ["how", "are", "you", "?"]) |> collect(): [_])
Both sequences must have the same element type, but the sequences themselves can be of different types:
(chain(0..2, [2, 3]) |> collect(): [_])
Reducing sequences
Ferlium also includes reduction-style functions that consume a sequence and produce a summary value.
Quantifiers and search
Use all and any to test predicates over a whole sequence:
[0, 1, 2] |> all(|x| x >= 0)
[0, 1, 2] |> any(|x| x > 1)
Use find to retrieve the first matching element, and position to retrieve its index:
[0, 1, 3] |> find(|x| x > 1)
[0, 1, 3] |> position(|x| x > 1)
Counting and aggregation
Use count to count elements and sum to add them:
2..5 |> count()
[2, 5] |> sum()
average divides the sum by the count:
[2.0, 4.0, 6.0] |> average()
Use join to concatenate a sequence of concatenable values with a separator between elements:
join(["a", "b", "c"], ",")
When the sequence is empty, join returns empty() for the element type.
You can combine join with other functions:
join(chain(["Hello", "world"], ["how", "are", "you", "?"]), " ")
Building sequences in place
Use concat, chain, and join when you want to produce a new value.
Use the in-place functions when you are incrementally building a mutable string or array.
let mut text = "Hello";
string_push_str(text, ", ");
string_push_str(text, "world");
text
let mut xs = [];
array_append(xs, 1);
array_append(xs, 2);
xs
For arrays, array_prepend inserts at the front:
let mut xs = [2, 3];
array_prepend(xs, 1);
xs
These functions require a mutable binding because they update the existing value. For repeated incremental construction, prefer the in-place functions instead of repeatedly concatenating intermediate values.
Minimum and maximum
Use minimum and maximum when the element type is ordered:
[3, 1, 2] |> minimum()
[3, 1, 2] |> maximum()
These functions require a non-empty sequence and panic on an empty one.
Splitting sequences
Use split to divide a value around a non-empty separator and collect the parts into an array.
Splitting strings
split on a string with a string separator returns [string]:
split("a,b,c", ",")
This evaluates to ["a", "b", "c"]. Like in most languages, if the separator appears at the start, end, or consecutively, empty strings are produced as parts:
split(",a,,", ",")
This evaluates to ["", "a", "", ""].
Splitting arrays
split on [A] with an element separator A returns [[A]]:
split([0, 1, 0, 2, 0], 0)
This evaluates to [[], [1], [2], []].
You can also split with a subarray separator:
split([1, 0, 0, 2, 0, 0, 3], [0, 0])
This evaluates to [[1], [2], [3]].
The separator must be non-empty
Passing an empty separator is a runtime error:
split("abc", "") // error: separator must not be empty
;
split([1, 2], []) // error: separator must not be empty
Relationship to join
split and join are inverses: splitting a value and then joining the parts with the same separator reconstructs the original:
join(split_iterator("a,b,c", ","), ",")
This evaluates to "a,b,c". Note the use of split_iterator here, which returns a lazy iterator over the parts rather than collecting them into an array.
Choosing between collections and iterators
A good rule of thumb is:
- use arrays directly when you want eager results
- use
iter(...)when you want lazy pipelines - use
collect()when you want to switch from lazy iteration back to an eager collection
This keeps code readable and makes evaluation timing explicit from the shape of the pipeline.
What comes next
The next chapter introduces unordered collections, including set and map, and shows how they relate to iter() and collect().
Unordered Collections
Ferlium’s standard library includes two unordered collection types: set and map.
They are useful when membership or key lookup matters more than position.
Both are hash-based collections.
Sets
A set stores unique values of one element type.
Its type is written set<T>.
let values = set { 1, 2, 3 };
Set literals use braces, but their contents are plain values rather than named fields. Duplicate elements are ignored:
let values = set { 1, 2, 3, 2 };
len(values)
Set values can be arbitrary expressions, not just literals:
fn f(value) { value }
set { f("hi"), f("ho") }
An empty set needs type context:
let values: set<int> = set {};
Maps
A map stores key-value pairs.
Its type is written map<K, V>.
let labels = map { 1 => "one", 2 => "two" };
Inside a map literal, each entry is written key => value.
Keys are unique; if the same key appears more than once, the later value wins:
let labels = map { 1 => "one", 2 => "two", 1 => "uno" };
map_get(labels, 1)
Map entries can also use arbitrary expressions:
fn key(value) { value + 1 }
fn label(value) { value }
map { key(0) => label("hi"), key(1) => label("lo") }
An empty map needs type context:
let labels: map<int, string> = map {};
Building from sequences
collect() can build a set or map from a sequence or iterator.
([1, 2, 3] |> iter() |> collect(): set<_>)
For maps, the input sequence must yield (K, V) pairs:
([(1, "one"), (2, "two")] |> iter() |> collect(): map<_, _>)
This is often the easiest way to move from sequence processing into an unordered collection when the data already exists as a pipeline.
Common operations
The standard library provides a few helpers for working with these collections:
empty()creates an empty set or map when the target type is knownlen(collection)returns the number of stored elements or entriesset_insert(collection, value)inserts a value into a mutable set and returns whether it was newset_contains(collection, value)tests set membershipmap_insert(collection, key, value)inserts or replaces a key and returns the previous value if there was onemap_get(collection, key)looks up a key and returnsNonewhen it is absentmap_contains_key(collection, key)tests key membership
For example:
let mut values: set<int> = empty();
set_insert(values, 1);
set_insert(values, 1);
set_contains(values, 1)
let mut labels: map<int, string> = empty();
map_insert(labels, 1, "one");
map_insert(labels, 1, "uno");
map_get(labels, 1)
The second example replaces the earlier value for key 1, so it evaluates to Some("uno").
Order
Sets and maps do not preserve a meaningful order. Use them when you care about uniqueness or association, not position. If you need a stable, indexed sequence, use arrays instead.
What comes next
The next chapter covers Ferlium’s numeric types, their literal forms, and conversions.
Numbers
Ferlium has two numeric types: int for whole numbers and float for floating-point numbers. This chapter covers the literal forms they accept, how they convert, and the small differences that affect arithmetic.
The two numeric types
int is a signed integer using the machine word width (typically 64 bits).
float is a finite IEEE 754 double-precision number. It excludes NaN and infinities, so equality and ordering on float always behave consistently.
let i: int = 42;
let f: float = 3.14;
(i, f)
Integer literals
Integer literals are written in three bases. All of them produce values of type int:
(42, 0b1010, 0xff)
- decimal:
42 - binary:
0b1010(digits0and1) - hexadecimal:
0xff(digits0–9,a–f,A–F; case-insensitive)
Negative literals use a leading -, which works with any base:
(-7, -0b101, -0xff)
Binary and hexadecimal forms are most useful for bit patterns, masks, and packed values; the next chapter on bitwise operations builds on them.
Float literals
Float literals contain a decimal point:
(0.5, 3.14, -2.0)
There is no scientific-notation literal yet; build large or small floats with arithmetic if you need them.
Mixing int and float
Integer and float values do not silently mix. However, an integer literal is unconstrained until context fixes its type, so it adapts to the surrounding expression:
1 + 2.0
Here 1 is treated as float because the other operand is float, so the result is a float.
When a numeric expression has no context to constrain it, Ferlium defaults it to int:
let x = 1 + 2;
x
x has type int.
Converting between int and float
Use as to convert explicitly:
let i = 5;
let f = i as float;
let j = 5.7 as int;
(f, j)
Float-to-int truncates toward zero, and both directions saturate at the target type’s bounds rather than wrapping or producing NaN or infinity.
Real constants
The Real trait provides common mathematical constants:
(Real::PI, Real::TAU, Real::E)
Real::PI is inferred from context and defaults to float when no more specific real type is required.
You can also spell the type explicitly:
Real::<float>::PI
Division and integer arithmetic
The / operator returns a float, even on integer operands:
10 / 3
This evaluates to 3.333…: float because / only has a float implementation, and the integer literals default to float to satisfy it.
For integer division, use the dedicated functions:
idiv(left, right)truncates toward zeroidiv_euclid(left, right)rounds toward negative infinityrem(left, right)returns the remainder ofidivmod(left, right)returns the Euclidean remainder
(idiv(10, 3), rem(10, 3), mod(-7, 3))
Both / on float and the integer division functions are fallible: dividing by zero produces a runtime error rather than inf or NaN. The chapter on effects explains how Ferlium tracks fallibility.
What comes next
The next chapter covers bitwise operations on integers and booleans through the Bits trait, where binary and hexadecimal literals are most at home.
Bitwise Operations
Ferlium provides bitwise operations through the Bits trait. They are useful for low-level encoding, flag sets, and packed representations. The standard library implements Bits for int and bool, so the same function names work on both.
Logical operations
bit_and, bit_or, bit_xor, and bit_not perform the usual boolean operations bit by bit:
bit_and(0b1101, 0b1011)
bit_or(0b1100, 0b0011)
bit_xor(0b1010, 0b1100)
bit_not(0)
bit_not(0) evaluates to -1 because int is a signed two’s-complement integer.
On bool, these collapse to the matching logical operators:
(bit_and(true, false), bit_or(true, false), bit_xor(true, true), bit_not(true))
Shifts and rotations
shift_left and shift_right move bits by a given amount, filling with zeros (shift right on a negative int preserves the sign):
shift_left(1, 4)
shift_right(0b10100, 2)
rotate_left and rotate_right move bits without losing them: bits that fall off one end re-enter at the other.
rotate_left(1, 3)
The shift and rotation amount is always an int.
Counting bits
count_ones and count_zeros return the number of set and unset bits:
count_ones(0b10110)
count_ones(true)
For int, count_zeros uses the full machine width (typically 64 bits), so count_zeros(0) returns the integer width. For bool, the width is 1.
Single-bit helpers
bit(position) constructs a value with only the bit at position set. Because the result type cannot be inferred from arguments alone, you usually need a type annotation:
(bit(3): int)
set_bit, clear_bit, and test_bit operate on a single bit of an existing value:
set_bit(0b1000, 1)
clear_bit(0b1111, 2)
test_bit(0b1010, 1)
These compose naturally:
let flags = set_bit(set_bit(0, 0), 3);
(test_bit(flags, 0), test_bit(flags, 1), test_bit(flags, 3))
Notes on bool
Because bool holds a single bit, some operations behave specially:
shift_leftandshift_rightalways returnfalse, since the bit is shifted out.rotate_leftandrotate_rightare the identity, since there is nowhere for the bit to move.bit(0)istrue;bit(n)for any othernisfalse.set_bit,clear_bit, andtest_bitonly affect position0; other positions leave the value unchanged or returnfalse.
This lets generic code over Bits work uniformly on both types without special casing.
What comes next
The next chapter shows how Ferlium values can be converted to JSON and plain text, through (de)serialization.
Serialization
Ferlium serializes typed values through a dynamic data model named DataValue.
The Serialize trait converts a typed value to DataValue, and Deserialize converts a DataValue back to an expected typed value.
let data = serialize({ host: "localhost", port: 8080 });
let config: { host: string, port: int } = deserialize(data);
Data Values
DataValue is an interchange tree used by serialization codecs:
DataValue::Null
DataValue::Unit
DataValue::Bool(true)
DataValue::Int(1)
DataValue::Float(1.5)
DataValue::String("hello")
DataValue::Array([DataValue::Int(1), DataValue::Int(2)])
DataValue::Tuple([DataValue::Int(1), DataValue::String("x")])
DataValue::Record([("host", DataValue::String("localhost"))])
DataValue::Set([DataValue::String("a"), DataValue::String("b")])
DataValue::Map([(DataValue::String("a"), DataValue::Int(1))])
DataValue::Variant { name: "Some", payload: DataValue::Tuple([DataValue::Int(1)]) }
Null represents external null data, such as JSON null.
Unit represents the Ferlium value ().
Arrays, tuples, records, sets, maps, and variants keep distinct shapes in DataValue.
JSON may still encode several of these as arrays or objects because JSON has fewer data shapes.
JSON
JSON is a codec over DataValue.
let text = json_encode({ host: "localhost", port: 8080 });
let config: { host: string, port: int } = json_decode(text);
JSON input maps to DataValue as follows:
nullbecomesNull- booleans, numbers, and strings become
Bool,IntorFloat, andString - arrays become
Array - objects become
Record
Maps are encoded to JSON as arrays of key-value pairs. This keeps the representation valid even when keys are not strings.
Ferlium Data Text
Ferlium data text is a human-readable codec over DataValue.
It accepts inert value syntax only; it does not evaluate Ferlium expressions.
let text = data_text_encode({
host: "localhost",
port: 8080,
retry: Some(3),
});
let config: { host: string, port: int, retry: Option<int> } = data_text_decode(text);
Data text supports:
null,(), booleans, numbers, and strings- arrays:
[1, 2, 3] - tuples:
(1, "x") - records:
{ host: "localhost", port: 8080 } - variants:
None,Some(1) - sets:
set { 1, 2, 3 } - maps:
map { "a" => 1, "b" => 2 } - comments and trailing commas
It does not support operators, function calls, bindings, control flow, imports, or string interpolation. The expected target type guides deserialization.
What comes next
The next chapter introduces effects, showing how Ferlium tracks reading, writing, and failure alongside ordinary values.
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) - Explicitly pure:
() -> () ! ()
This ! … suffix appears in inferred function signatures and IDE annotations.
Effect-related compilation errors also report effect sets (for example, incompatible effect dependencies).
You can also write effect annotations in function types when you need to constrain inference. Ordinary function definitions still infer their own effects; explicit source annotations are used for function types, trait method declarations, and trait or type effect bindings. Most code still relies on inference.
Effects are inferred and propagate through calls
You usually do not need to 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.
Generic code can abstract over effects in the same way it abstracts over types.
For example, a function parameter may have type (int) -> int ! E, where E stands for the callback’s effect set.
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.
The same mutable-passing rule applies to other in-place builders such as array_prepend and string_push_str.
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, 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.