Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Variables and Constants

let

let declares a local binding. Bindings are immutable by default:

let timeout_ms = 250
let name: string = "flint"

Use let mut for a binding you intend to modify:

let mut count: u32 = 0
count = count + 1     // ok

let x = 5
x = 6                 // error: cannot assign to immutable binding

Type annotations are optional when the type is obvious from context. Function signatures, struct fields, const, and static always require explicit types.

const

Constants are compile-time values. They always require a type:

const MAX_RETRIES: u32 = 3
const DEFAULT_BAUD: u32 = 115_200
const BANNER: string = "Flint 1.0-dev"

Rules:

  • Always immutable. There is no const mut.
  • Initializer must be computable at compile time.
  • Usable anywhere a value of that type is needed.
  • Naming convention: UPPER_SNAKE_CASE.

static

static is for module-scope storage with a fixed program lifetime:

static BOOT_BANNER: string = "Flint"

Rules:

  • static is immutable. There is no static mut.
  • Must have an explicit type.
  • Initializer should be compile-time evaluable.
  • Lives in read-only image storage on MCU targets.

Use static when a value must exist for the entire program lifetime, not just at compile time.

Mutability

Mutability in Flint attaches to the binding, not the value. A mutable binding lets you reassign it and call mutating methods:

let point = Point { x: 1, y: 2 }
point.x = 3              // error: immutable binding

let mut point = Point { x: 1, y: 2 }
point.x = 3              // ok
point.translate(1, 1)    // ok (translate takes mut self)

Assignment operators work as expected on mutable bindings:

let mut n: u32 = 10
n += 5    // ok
n -= 2    // ok
n *= 3    // ok

There is no ++ or --. Use += 1 and -= 1.

Type Inference

Local let bindings may omit the type when it is clear from context:

let x = 42              // i32 (default for unsuffixed integer)
let y = 1.5             // f32 (default for unsuffixed float)
let s = "hello"         // string
let pin = gpio.pin(25)  // inferred from gpio.pin return type

When inference is ambiguous, you must annotate:

let buf = [0; 64]          // error: what is the element type?
let buf: [u8; 64] = [0; 64]  // ok

Shadowing

Nested scopes may shadow outer bindings:

let value = 10
{
    let value = value * 2  // shadows outer `value` in this scope
    log.info(value)        // 20
}
log.info(value)            // 10 (outer binding restored)

Same-scope shadowing is a compile error:

let port = 8080
let port = 9090     // error: redeclaration in the same scope

let mut x = 5
let x = 10          // error: redeclaration in the same scope

Use if let and match for the common pattern of binding an inner refined value:

let raw = env.lookup("PORT")
if let Some(raw) = raw {
    let port = u16.try_from(parse_i32(raw)?)?
    use_port(port)
}

Option

Option<T> is how Flint represents a value that might not be there. There is no null in Flint. A function that might return nothing says so explicitly in its return type.

fn find_device(id: u32) -> Option<Device> {
    // returns Some(device) or None
}

let device = find_device(42)

// Pattern match to unwrap safely
match device {
    Some(d) => d.init(),
    None => log.warn("device not found"),
}

// Or use if let for the happy path
if let Some(d) = find_device(42) {
    d.init()
}

If you only need to know whether a value is present and do not need to bind it, use is_some() or is_none() instead of if let Some(...).

None can never be dereferenced. The compiler forces you to handle it before using the value. No null pointer exceptions, no segfaults from a forgotten check.

See Error Handling for how Option composes with ? and Result.

Result

Result<T, E> is how Flint represents operations that can fail. There are no exceptions and no try/catch. A function that can fail says so in its return type, and the caller decides what to do.

fn read_sensor() -> Result<u16, Error> {
    // returns Ok(reading) or Err(reason)
}

// Handle explicitly
match read_sensor() {
    Ok(v) => process(v),
    Err(e) => log.error(e),
}

// Or propagate with ?
fn run() -> Result<(), Error> {
    let reading = read_sensor()? // returns Err early if it failed
    process(reading)
    return Ok()
}

? is the idiomatic way to propagate errors up the call stack without nesting. Control flow is never interrupted invisibly. Every exit path is visible in the source.

When the success type is (), Ok() is enough. The unit value is inferred.

See Error Handling for the full picture.

Global Mutable State

Raw mutable globals do not exist in Flint. For global initialization patterns, use core/sync:

use core/sync

// Single-assignment cell, safe to access from any code after init
static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()

fn init_device() {
    DEVICE_ID.set(read_unique_id())
}

core/sync.OnceCell<T> and core/sync.Lazy<T> are the safe patterns for globals that need runtime initialization.