Iron-clad programswithout the ceremony.
Ferron is statically typed and compiled to native code. Effects, errors, and resources are visible in the code — with no borrow checker, no lifetimes, no null, and no exceptions.
use std.env.Env
use std.math
type Percent = Int where value >= 0 and value <= 100
type Shape =
| Circle(radius: f64)
| Rect(width: f64, height: f64)
fn area(shape: Shape) -> f64 {
return match shape {
Circle(radius) => math.PI * radius * radius
Rect(width, height) if width == height => width * width
Rect(width, height) => width * height
}
}
fn main(env: Env) -> Unit {
let shapes = [Circle(1.0), Rect(2.0, 3.0)]
let total = shapes.map(area).fold(0.0, |sum, a| sum + a)
env.console.print("total area: {total:.2f}\n")
let tags = " red, green ,blue ".trim().split(",")
for tag in tags {
env.console.print("[" + tag.trim().to_upper() + "]\n")
}
// Checked at compile time: try `Percent.new(142)`.
let done = Percent.new(42)
env.console.print("done: {done}%\n")
}
Real Ferron: the compiler runs in your browser. Edit it and press Ctrl + Enter.
Why Ferron
The safety of a strict type system, without the bookkeeping that usually comes with it.
A tour in runnable examples
Every snippet is editable and runs right here. Break them on purpose — the compiler errors are part of the pitch.
01 · Data
Algebraic data types, exhaustive match
Model your domain as sum types with named payloads. Matching is checked — guards, wildcards, nested patterns — and the compiler names any case you forgot.
Try it: Delete the Free => arm and run.
use std.env.Env
type Payment =
| Card(last4: String, cents: Int)
| Transfer(iban: String, cents: Int)
| Voucher(code: String)
| Free
fn describe(payment: Payment) -> String {
// Remove an arm and the compiler names the missing case.
return match payment {
Card(last4, cents) if cents > 100_00 => "large card payment •••• {last4}"
Card(last4, _) => "card •••• {last4}"
Transfer(iban, cents) => "transfer of {cents / 100} from {iban}"
Voucher(code) => "voucher {code.to_upper()}"
Free => "on the house"
}
}
fn main(env: Env) -> Unit {
let payments = [
Card("4242", 250_00),
Card("1881", 12_50),
Transfer("DE89 3704", 99_00),
Voucher("spring-24"),
Free,
]
for payment in payments {
env.console.print("{describe(payment)}\n")
}
}
02 · Types
Refinement types and newtypes
Attach a predicate to a type. Literals are proven at compile time; runtime values go through a constructor that returns a Result, so invalid data never gets in.
Try it: Change Port.new(8080) to Port.new(70000).
use std.env.Env
// A refinement type: an Int the compiler keeps between 1 and 65535.
type Port = Int where value >= 1 and value <= 65535
// A newtype with a predicate: a distinct type with a checked constructor.
newtype Email = String where value.contains("@") and value.len() > 3
fn connect(host: String, port: Port) -> String {
return "connecting to {host}:{port}"
}
fn main(env: Env) -> Unit {
// Literals are checked at compile time. Try `Port.new(70000)`.
env.console.print("{connect("localhost", Port.new(8080))}\n")
// Runtime values come back as a Result you must handle.
for input in ["ada@example.com", "not-an-email"] {
match Email.new(input) {
Ok(email) => env.console.print("valid: {email.value()}\n")
Err(error) => env.console.print("rejected {input}: {error}\n")
}
}
}
03 · Safety
There is no null
A value that may be missing is an Option, and the type system won't let you forget it. This program does not compile — on purpose.
Try it: Apply the fix from the comment and run again.
use std.env.Env
struct User {
name: String,
email: Option[String],
}
fn main(env: Env) -> Unit {
let user = User { name: "Ada", email: None }
// There is no null: a maybe-missing value is an Option you must unpack.
let address: String = user.email
// Fix: match on it, or give a default.
// let address = user.email.unwrap_or("no email")
env.console.print("{user.name} <{address}>\n")
}
04 · Errors
Errors are values, with context
Expected failures travel in Result. ? propagates them; any error converts into AnyError, which collects a readable context chain. Aborts are reserved for real bugs.
use std.env.Env
use std.error.{AnyError, Error}
type ConfigError =
| Missing(key: String)
| Invalid(key: String, value: String)
impl Display for ConfigError {
fn to_string(self) -> String {
return match self {
Missing(key) => "missing key `{key}`"
Invalid(key, value) => "`{key}` is not a number: {value}"
}
}
}
impl Error for ConfigError {}
fn lookup(pairs: List[(String, String)], key: String) -> Result[String, ConfigError] {
return match pairs.find(|pair| pair.0 == key) {
Some(pair) => Ok(pair.1)
None => Err(Missing(key))
}
}
fn port(pairs: List[(String, String)]) -> Result[Int, ConfigError] {
let text = lookup(pairs, "port")? // `?` returns the error early
return match Int.parse(text) {
Ok(number) => Ok(number)
Err(_) => Err(Invalid("port", text))
}
}
fn load(pairs: List[(String, String)]) -> Result[Int, AnyError] {
// Any `Error` converts into `AnyError`, which carries a context chain.
return Ok(port(pairs).context("loading server config")?)
}
fn main(env: Env) -> Unit {
let good = [("host", "example.com"), ("port", "8080")]
let typo = [("host", "example.com"), ("port", "80eighty")]
let empty: List[(String, String)] = []
for pairs in [good, typo, empty] {
match load(pairs) {
Ok(number) => env.console.print("listening on {number}\n")
Err(error) => env.console.print("error: {error}\n")
}
}
}
05 · Effects
Capabilities instead of ambient authority
main receives env: Env. Every effect — console, files, network, clock, randomness, processes — goes through one of its fields. A function that isn't handed a capability cannot touch the outside world, and its signature says so.
Try it: Add console.print("hi") inside total.
use std.env.Env
use std.io.Console
// No capability in the signature: this function cannot print, read files,
// open sockets, or look at the clock. Try adding a `console.print` here.
fn total(prices: List[Int]) -> Int {
return prices.fold(0, |sum, price| sum + price)
}
// This one may print, and only print: it was handed the console.
fn report[T](console: Console, label: String, value: T) -> Unit where T: Display {
console.print("{label}: {value}\n")
}
fn main(env: Env) -> Unit {
// `main` receives every capability in `env` (fs, net, clock, console,
// random, actors, ...) and hands out only what each function needs.
let prices = [120, 80, 45]
report(env.console, "items", prices.len())
report(env.console, "total", total(prices))
}
06 · Resources
Ownership without lifetimes
Files and sockets close exactly once. Parameters borrow by default, take owns, and move hands a resource over. Borrows can't be stored or returned, so there are no lifetime annotations — yet use-after-move is a compile error.
Try it: Drop the move on line 16: giving up ownership must be written out.
use std.env.Env
use std.fs.{File, Path}
use std.io.IoError
fn count_lines(file: File) -> Result[usize, IoError] { // borrows
return Ok(file.read_lines()?.len())
}
fn archive(file: take File) -> Result[String, IoError] { // owns it, closes it on return
return Ok(file.read_text()?)
}
fn main(env: Env) -> Result[Unit, IoError] {
let file = env.fs.open(Path.from("notes.txt"), "r")?
let lines = count_lines(file)? // borrowed: still ours
let text = archive(move file)? // handed over, closed by `archive`
let again = count_lines(file)? // the compiler stops this
env.console.print("{lines} {again} {text.len()}\n")
return Ok(Unit)
}
07 · Concurrency
Typed actors in structured scopes
An actor declares the messages it receives and owns its state. Scopes join their actors on exit and cancel them on error. Natively they run on a thread pool, with supervision, timers, and channels built in.
use std.actor.ActorError
use std.env.Env
use std.io.Console
// The protocol is a type: a `Pid[Command]` accepts only `Command`s.
type Command: send =
| Deposit(amount: Int)
| Withdraw(amount: Int)
| Report
// Each actor owns its state and handles one message at a time:
// no locks, no shared mutable memory, no data races.
actor Account(owner: String, console: Console) receives Command {
var balance: Int = 0
on Deposit(amount) {
balance = balance + amount
continue
}
on Withdraw(amount) {
if amount > balance {
console.print("{owner}: declined {amount}, balance is {balance}\n")
} else {
balance = balance - amount
}
continue
}
on Report {
console.print("{owner}: {balance}\n")
exit
}
}
fn main(env: Env) -> Result[Unit, ActorError] {
// Leaving the `with` block joins every child; an error cancels them first.
with scope = env.actors.scope()? {
let ada = scope.spawn(Account("ada", env.console))?
let bob = scope.spawn(Account("bob", env.console))?
ada.send(Deposit(100))?
bob.send(Deposit(20))?
bob.send(Withdraw(50))?
ada.send(Withdraw(30))?
ada.send(Report)?
bob.send(Report)?
}
env.console.print("every account reported\n")
return Ok(Unit)
}
08 · Abstraction
Traits, generics, operators
Traits with default methods, generic functions with where bounds, operator overloading through Add and Mul, and Display for interpolation. Monomorphized: abstractions cost nothing at runtime.
use std.env.Env
use std.math
#[derive(Eq)]
struct Vec2 {
x: f64,
y: f64,
}
impl Add[Vec2] for Vec2 {
type Output = Vec2
fn add(self, rhs: Vec2) -> Vec2 {
return Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
impl Mul[f64] for Vec2 {
type Output = Vec2
fn mul(self, k: f64) -> Vec2 {
return Vec2 { x: self.x * k, y: self.y * k }
}
}
impl Display for Vec2 {
fn to_string(self) -> String {
return "({self.x}, {self.y})"
}
}
trait Shape {
fn area(self) -> f64
fn name(self) -> String
// A default method every implementation gets for free.
fn describe(self) -> String {
return "{self.name()} with area {self.area():.2f}"
}
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(self) -> f64 { return math.PI * self.radius * self.radius }
fn name(self) -> String { return "circle" }
}
impl Shape for Square {
fn area(self) -> f64 { return self.side * self.side }
fn name(self) -> String { return "square" }
}
// Generic over anything that is a Shape.
fn largest[T](shapes: List[T]) -> f64 where T: Shape {
return shapes.fold(0.0, |best, s| if s.area() > best { s.area() } else { best })
}
fn main(env: Env) -> Unit {
let velocity = Vec2 { x: 1.5, y: -2.0 }
let position = Vec2 { x: 10.0, y: 4.0 } + velocity * 2.0
env.console.print("position: {position}\n")
env.console.print("equal: {position == Vec2 { x: 13.0, y: 0.0 }}\n")
env.console.print("{Circle { radius: 2.0 }.describe()}\n")
env.console.print("{Square { side: 3.0 }.describe()}\n")
env.console.print("largest square: {largest([Square { side: 2.0 }, Square { side: 5.0 }])}\n")
}
09 · Style
Method chains, closures, lazy iterators
Methods chain with . and module functions are called directly. Ranges can be endless; lazy adapters pull only what take asks for.
use std.env.Env
fn is_prime(n: Int) -> Bool {
if n < 2 { return false }
var d = 2
while d * d <= n {
if n % d == 0 { return false }
d += 1
}
return true
}
fn main(env: Env) -> Unit {
// Methods chain with `.`; module functions are called directly.
let slug = " Hello, Ferron World! "
.trim()
.to_lower()
.replace(",", "")
.replace("!", "")
.replace(" ", "-")
env.console.print("slug: {slug}\n")
// Ranges can be endless: lazy adapters pull only what `take` needs.
let primes: List[Int] = (1..)
.filter(is_prime)
.take(10)
.collect()
env.console.print("primes: {primes}\n")
let words = ["anvil", "forge", "iron", "spark", "hammer", "ingot"]
let long = words.filter(|w| w.len() > 4).map(|w| w.to_upper())
env.console.print("long words: {long}\n")
let letters = words.fold(0usize, |sum, w| sum + w.len())
env.console.print("letters: {letters}\n")
}
10 · Data formats
Derive your serializers
#[derive(Data)] generates encoders and decoders for structs and ADTs. JSON, TOML, and YAML share one model, and malformed input becomes a typed error with a path to the bad field.
use std.env.Env
use std.json
#[derive(Eq, Data)]
type Role =
| Admin
| Member(since: Int)
#[derive(Eq, Data)]
struct User {
name: String,
email: Option[String],
roles: List[Role],
}
fn main(env: Env) -> Unit {
let ada = User { name: "Ada", email: None, roles: [Admin, Member(1843)] }
// `#[derive(Data)]` writes the encoder and the decoder for you.
let text = json.encode(ada).pretty(2)
env.console.print("{text}\n")
match User.from_json(text) {
Ok(back) => env.console.print("round trip equal: {back == ada}\n")
Err(error) => env.console.print("decode failed: {error}\n")
}
// Bad input is a typed error with a path, never a crash. Raw strings
// need no escapes and no `{{`, so JSON reads as JSON.
match User.from_json(r#"{"name": "Bob", "roles": ["Owner"]}"#) {
Ok(_) => env.console.print("decoded?\n")
Err(error) => env.console.print("rejected: {error}\n")
}
}
11 · Numbers & text
Numbers and strings you can trust
Exact decimals for money. Integer overflow aborts in debug builds instead of wrapping silently; wrapping, checked, and saturating math are explicit. Strings are UTF-8 and count graphemes. Format specs work like Python's.
Try it: Remove the & on the last line.
use std.env.Env
use std.math
fn main(env: Env) -> Unit {
// Decimals are exact: money adds up.
env.console.print("0.1 + 0.2 = {0.1dec + 0.2dec}\n")
env.console.print("exact: {0.1dec + 0.2dec == 0.3dec}\n")
// Strings count what people see: grapheme clusters.
let word = "naïve 👩🚀"
env.console.print("{word} has {word.len()} characters\n")
// Overflow aborts in debug builds. Wrapping is explicit, checking is a method.
let small: u8 = 250
env.console.print("wrapping: {small &+ 10}\n")
env.console.print("checked: {small.checked_add(10)}\n")
env.console.print("saturating: {small.saturating_add(10)}\n")
// Python-style format specs.
env.console.print("[{math.PI:>10.3f}] [{1234567:,}] [{255:#x}] [{"left":<8}]\n")
// Remove the `&` below and run: a debug build stops instead of wrapping.
env.console.print("{small &+ 10}\n")
}
12 · Standard library
Batteries included
Written mostly in Ferron itself, with Python-level breadth: collections, regex, dates and time zones, hashing and encodings, UUIDs, JSON/TOML/YAML/CSV, CLI parsing, terminal UI, networking, and processes.
use std.datetime.Date
use std.encoding.base64
use std.env.Env
use std.hash.Sha256
use std.id.Uuid
use std.regex.Regex
use std.toml
#[derive(Data)]
struct Server {
host: String,
port: Int,
}
#[derive(Data)]
struct Config {
server: Server,
}
fn main(env: Env) -> Unit {
// Regular expressions with named groups.
let date = Regex.new(r"(?<year>\d{4})-(?<month>\d\d)-(?<day>\d\d)").expect_ok("valid")
match date.captures("released on 2024-02-29, patched later") {
Some(found) => env.console.print("year {found.name("year").unwrap_or("?")}\n")
None => env.console.print("no date\n")
}
// Calendar dates.
let leap = Date.parse_iso("2024-02-29").expect_ok("ISO date")
env.console.print("{leap.format("%A, %d %B %Y")}\n")
// Hashing and encodings.
env.console.print("sha256: {"ferron".utf8_bytes().digest(Sha256)}\n")
env.console.print("base64: {base64.encode("forged".utf8_bytes())}\n")
// Name-based UUIDs.
env.console.print("uuid: {Uuid.v5(Uuid.DNS, "ferron-lang.org")}\n")
// Typed config straight from TOML (JSON, YAML, and CSV work the same way).
// Raw strings keep their text verbatim, across lines, with no escapes.
let text = r#"
[server]
host = "0.0.0.0"
port = 8080
"#
let config = Config.from_toml(text).expect_ok("parses")
env.console.print("listening on {config.server.host}:{config.server.port}\n")
}
Playground
The compiler front end and an interpreter, compiled to WebAssembly. Nothing leaves your machine; file, network, and process access exist only in native builds.
use std.env.Env
use std.math
type Percent = Int where value >= 0 and value <= 100
type Shape =
| Circle(radius: f64)
| Rect(width: f64, height: f64)
fn area(shape: Shape) -> f64 {
return match shape {
Circle(radius) => math.PI * radius * radius
Rect(width, height) if width == height => width * width
Rect(width, height) => width * height
}
}
fn main(env: Env) -> Unit {
let shapes = [Circle(1.0), Rect(2.0, 3.0)]
let total = shapes.map(area).fold(0.0, |sum, a| sum + a)
env.console.print("total area: {total:.2f}\n")
let tags = " red, green ,blue ".trim().split(",")
for tag in tags {
env.console.print("[" + tag.trim().to_upper() + "]\n")
}
// Checked at compile time: try `Percent.new(142)`.
let done = Percent.new(42)
env.console.print("done: {done}%\n")
}
One compiler, every tool
Two drivers share one compiler: anvil for packages, ferron for single files and integrations.
# packages$ anvil new hello # scaffold a package$ anvil run -- args # build natively and run$ anvil test # tests live next to the code$ anvil fmt --check # one canonical format$ anvil graph --actors # who spawns and messages whom# single files and integrations$ ferron run main.fer$ ferron lsp # language server$ ferron serve --stdio # JSON-RPC for tools and agents
Get started
Ferron is built from source with Cargo while the language is in development.
$ git clone https://github.com/ferron-lang/ferron$ cd ferron$ cargo run -p anvil-cli -- new hello$ cd hello && cargo run -p anvil-cli -- run
Ferron