in active development

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.

statically typedcapability-passing I/Otyped actorsCranelift native code
taste.fer
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")
}
Ctrl + Enter runs it in your browser

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.

Effects you can read
No ambient authority. Files, network, and clock are reached only through capabilities handed down from main, so effects show up in signatures and tests swap them out.
Resources that close themselves
Files and sockets are affine: they close exactly once when their owner's scope ends. Parameters borrow by default; take and move hand ownership over. No lifetimes.
Errors as values
Result, Option, ?, exhaustive match, and AnyError with context chains. No null, no exceptions, no surprises at 3 a.m.
Concurrency without data races
Typed actors live in structured scopes that join or cancel their children. Messages are copied, so actors never share mutable state.
Expressive types
Generics, traits, ADTs, refinement types checked at compile time, and zero-cost newtypes.
Batteries and tooling
JSON, TOML, YAML, regex, dates, hashing, CLI parsing — plus a package manager, test runner, formatter, and language server from one compiler.

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.

adts.fer
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")
  }
}
Ctrl + Enter runs it in your browser

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).

refinement.fer
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")
    }
  }
}
Ctrl + Enter runs it in your browser

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.

nulls.fer
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")
}
Ctrl + Enter runs it in your browser

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.

errors.fer
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")
    }
  }
}
Ctrl + Enter runs it in your browser

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.

capabilities.fer
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))
}
Ctrl + Enter runs it in your browser

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.

resources.fer
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)
}
Ctrl + Enter runs it in your browser

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.

actors.fer
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)
}
Ctrl + Enter runs it in your browser

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.

traits.fer
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")
}
Ctrl + Enter runs it in your browser

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.

chains.fer
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")
}
Ctrl + Enter runs it in your browser

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.

json.fer
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")
  }
}
Ctrl + Enter runs it in your browser

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.

numbers.fer
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")
}
Ctrl + Enter runs it in your browser

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.

batteries.fer
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")
}
Ctrl + Enter runs it in your browser

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.

playground.fer
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")
}
Ctrl + Enter runs it in your browser

One compiler, every tool

Two drivers share one compiler: anvil for packages, ferron for single files and integrations.

terminal
# 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
Readable diagnostics. Source snippets by default; JSON output carries machine-applicable fixes.
Language server. Diagnostics, hover, go to definition, references, rename, and formatting.
Tests. #[test] functions, property checks, and a deterministic actor scheduler.
Dependencies. From paths or git, pinned to exact commits in anvil.lock.
Native binaries. Through Cranelift, with fast incremental builds.

Get started

Ferron is built from source with Cargo while the language is in development.

terminal
$ 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