Getting Started

Everything you need to know to start writing FezLang. Takes about 15 minutes.

Install

One command on macOS or Linux:

curl -sSL https://fezlang.org/install.sh | sh

Verify:

$ fez --version
fez 1.0.0

See Install for manual download, build from source, and Windows support.

Hello World

Create hello.fez:

io.print("hello, world")

Run it:

$ fez run hello.fez
hello, world

No imports needed for io — it's available everywhere. No main function, no boilerplate.

Variables & Types

Variables are mutable by default with type inference. Use const for immutability.

// Type inference — the compiler knows the types
name = "FezLang"        // str
version = 1             // int
pi = 3.14159            // f64
active = true           // bool

// Explicit types when you want them
count: int = 0
ratio: f64 = 0.5

// Constants — cannot be reassigned
const MAX_SIZE = 1024
const APP_NAME = "MyApp"

// Mutable — reassignment is fine
x = 10
x = 20  // OK

Primitive types: int, f64, str, bool, byte, err.

Functions

Declared with fn. Arguments are typed. Return types come after ->.

fn greet(name: str) -> str {
  return "hello, {name}!"
}

// Multiple return values
fn divide(a: f64, b: f64) -> f64, err {
  if b == 0.0 {
    return 0.0, error("division by zero")
  }
  return a / b, nil
}

// Calling functions
msg = greet("Cal")
io.print(msg)

result, err = divide(10.0, 3.0)
if err {
  io.print(err.message)
} else {
  io.print("result: {result}")
}

Functions are first-class values — you can pass them as arguments and return them from other functions.

Modules

Modules organize code. They hold functions, constants, and nested modules. They do not hold mutable state.

module math_utils {
  const PI = 3.14159

  fn circle_area(r: f64) -> f64 {
    return PI * r * r
  }

  // Modules can nest
  module convert {
    fn deg_to_rad(deg: f64) -> f64 {
      return deg * math_utils.PI / 180.0
    }
  }
}

// Access with dot syntax — always a path
area = math_utils.circle_area(5.0)
rad = math_utils.convert.deg_to_rad(90.0)
io.print("area: {area}, rad: {rad}")

The dot is always a module path or struct field access. Never a method call.

Structs

Structs are plain data containers. No methods, no constructors, no inheritance.

struct Point {
  x: f64
  y: f64
}

struct Rect {
  origin: Point
  width: f64
  height: f64
}

// Instantiate with named fields
p = Point { x: 10.0, y: 20.0 }
io.print("({p.x}, {p.y})")

// Nested structs
r = Rect {
  origin: Point { x: 0.0, y: 0.0 },
  width: 100.0,
  height: 50.0,
}

// Mutation — structs are mutable by default
p.x = 30.0

Functions operate on structs — structs don't have methods. This keeps data and behavior separate.

Enums

Simple enumerated types with implicit integer values.

enum Color {
  Red     // 0
  Green   // 1
  Blue    // 2
}

enum Direction {
  North
  South
  East
  West
}

c = Color.Red
if c == Color.Red {
  io.print("It's red!")
}

d = Direction.East
io.print("heading: {d}")  // 2

Error Handling

No exceptions. Functions that can fail return an err alongside their result.

// Create errors with error()
fn parse_age(s: str) -> int, err {
  n = math.parse_int(s)
  if n < 0 {
    return 0, error("age cannot be negative")
  }
  return n, nil
}

// Check errors
age, err = parse_age("25")
if err {
  io.print("Error: {err.message}")
}

// Ignore errors explicitly with _
_ = file.delete("temp.txt")

// Propagation pattern — check and re-wrap
fn load_user(id: int) -> User, err {
  data, err = db.query("SELECT * FROM users WHERE id = ?", id)
  if err {
    return User{}, error("load_user failed: {err.message}")
  }
  return parse_user(data)
}

Control Flow

Standard if/else, for loops over arrays/ranges/maps, while, break, continue.

// If/else
if x > 10 {
  io.print("big")
} else if x > 5 {
  io.print("medium")
} else {
  io.print("small")
}

// For over array
items = ["apple", "banana", "cherry"]
for item in items {
  io.print(item)
}

// For with index
for i, item in items {
  io.print("{i}: {item}")
}

// For over range
for i in 0..10 {
  io.print("{i}")  // 0 through 9
}

// For over map
ages = {"cal": 30, "luna": 5}
for key, val in ages {
  io.print("{key} is {val}")
}

// While
count = 0
while count < 5 {
  io.print("{count}")
  count = count + 1
}

// Break and continue
for i in 0..100 {
  if i % 2 == 0 { continue }
  if i > 10 { break }
  io.print("{i}")
}

Closures & Lambdas

Lambdas use |params| expr syntax. They capture variables by copy.

// Lambda syntax
double = |x| x * 2
io.print(double(5))  // 10

// Multi-param
add = |a, b| a + b
io.print(add(3, 4))  // 7

// Pass to higher-order functions
numbers = [1, 2, 3, 4, 5]
evens = filter(numbers, |n| n % 2 == 0)

// Function factories
fn make_multiplier(factor: int) -> fn(int) -> int {
  return |x| x * factor  // captures factor by copy
}

triple = make_multiplier(3)
io.print(triple(7))  // 21

Closures capture by copy — the original variable is never modified through the closure.

Concurrency

Spawn lightweight tasks. Communicate through channels. Wait for completion.

// Spawn a concurrent task
handle = spawn {
  time.sleep(1000)
  io.print("done!")
}
handle.wait()

// Channels for communication
ch = channel(1)
spawn {
  ch <- "hello from spawned task"
}
msg = <-ch
io.print(msg)

// Parallel work pattern
fn fetch_all(urls: []str) -> []str {
  ch = channel(len(urls))
  for url in urls {
    spawn {
      resp, _ = http.get(url)
      ch <- resp.body
    }
  }
  results = []
  for _ in urls {
    results = append(results, <-ch)
  }
  return results
}

Spawned tasks capture variables by copy. The compiler enforces this — no shared mutable state.

Ref Parameters

By default, arguments are passed by value. Use ref for in-place mutation — required at both declaration and call site.

fn increment(counter: ref int) {
  counter = counter + 1
}

x = 0
increment(ref x)
io.print(x)  // 1

// Ref is explicit at both sites — mutation is always visible
fn swap(a: ref int, b: ref int) {
  temp = a
  a = b
  b = temp
}

p = 5
q = 10
swap(ref p, ref q)
io.print("{p} {q}")  // 10 5

String Interpolation

Use {expr} inside double-quoted strings. Backtick strings are raw (no interpolation).

name = "Cal"
age = 30

// Interpolation with {}
io.print("Hello, {name}! You are {age} years old.")
io.print("Next year: {age + 1}")

// Raw strings with backticks — no interpolation
pattern = `\d+\.\d+`
io.print(pattern)  // \d+\.\d+

// Escape sequences in double-quoted strings
io.print("line 1\nline 2")
io.print("tab\there")
io.print("quote: \"hello\"")

Defer

Defer runs a statement when the current function returns. Multiple defers execute in LIFO (last-in, first-out) order.

fn process() {
  f = file.open("data.txt")
  defer file.close(f)  // runs when process() returns

  lock = acquire_lock()
  defer release_lock(lock)  // runs before file.close

  // Work with f and lock...
  // Both are cleaned up automatically
}

// Common pattern: open/close, lock/unlock, connect/disconnect
fn query_db(sql: str) -> []Row, err {
  conn, err = db.connect("postgres://localhost/app")
  if err { return [], err }
  defer db.close(conn)

  return db.query(conn, sql)
}

Your first real program

Let's build a CLI TODO app that uses everything we've learned — structs, modules, arrays, while loop, and user input.

struct Task {
  id: int
  text: str
  done: bool
}

module todo {
  next_id = 1
  tasks = []

  fn add(text: str) -> Task {
    task = Task { id: next_id, text: text, done: false }
    tasks = append(tasks, task)
    next_id = next_id + 1
    return task
  }

  fn complete(id: int) -> bool {
    for i, task in tasks {
      if task.id == id {
        tasks[i].done = true
        return true
      }
    }
    return false
  }

  fn list() {
    if len(tasks) == 0 {
      io.print("  No tasks yet.")
      return
    }
    for task in tasks {
      mark = if task.done { "x" } else { " " }
      io.print("  [{mark}] #{task.id}: {task.text}")
    }
  }
}

io.print("FezTodo — type 'help' for commands")

while true {
  input = io.read("> ")
  parts = str.split(str.trim(input), " ")
  cmd = parts[0]

  if cmd == "quit" { break }
  else if cmd == "help" {
    io.print("  add   — add a task")
    io.print("  done    — mark task complete")
    io.print("  list        — show all tasks")
    io.print("  quit        — exit")
  }
  else if cmd == "add" {
    text = str.join(parts[1:], " ")
    task = todo.add(text)
    io.print("  Added #{task.id}: {task.text}")
  }
  else if cmd == "done" {
    id = math.parse_int(parts[1])
    if todo.complete(id) {
      io.print("  Completed #{id}")
    } else {
      io.print("  Task not found")
    }
  }
  else if cmd == "list" { todo.list() }
  else { io.print("  Unknown command. Type 'help'.") }
}

Save as todo.fez and run with fez run todo.fez. That's a complete interactive application in under 60 lines — structs for data, a module for organization, functions for behavior.

Next: Language Reference — the complete guide to every FezLang feature.