Type System

Primitive Types

pub struct Primitives {
  text: String,           // Text data
  count: I32,             // 32-bit signed integer
  amount: F64,            // 64-bit IEEE 754 float
  active: Boolean         // true or false
}

Numeric Types

FormaLang has four width-tagged numeric primitives instead of a single generic Number type. Backends emit native instructions directly without guessing precision.

TypeRange / shape
I3232-bit signed integer
I6464-bit signed integer
F3232-bit IEEE 754 float
F6464-bit IEEE 754 float

The type of a literal

A literal with no suffix takes its type from the position where it stands. An annotation, a parameter, a field, a variant field, a return type and a container element each give a type. Where no type is given, an integer literal is an I32 and a literal with a fraction or an exponent is an F64. A suffix in uppercase gives the type at the literal itself:

pub let a = 42                          // I32: no type is given
pub let b = 42I64                       // I64: the suffix
pub let c = 3.14                        // F64: no type is given
pub let d = 3.14F32                     // F32: the suffix

pub let big: I64 = 9_223_372_036_854_775_807   // the annotation gives I64
pub let tiny: F32 = 0.5                 // the annotation gives F32
pub let ids: [I64] = [1, 2, 3]          // each element is an I64

pub fn twice(n: I64) -> I64 { n * 2I64 }
pub let four: I64 = twice(n: 2)         // the parameter gives I64

An operand of a binary operator takes its type from the other operand. An empty array literal [] and an empty dictionary literal [:] do the same, on either side of == and !=: a == [] compares a with an empty array of its own type. When both operands are literals, an arithmetic operator (+, -, *, /, %) gives them the type of its own position:

pub fn next(n: I64) -> I64 {
  n + 1            // 1 is an I64: the other operand gives the type
}

pub let product: I64 = 2 * 3          // 2 and 3 are I64: the annotation
pub let wide: Boolean = 5 < 3_000_000_000I64   // 5 is an I64

pub fn run_checks() {
  assert(condition: next(n: 1) == 2)
  assert(condition: product == 6)
  let none: [I64] = []
  assert(condition: none == [] && [] == none)
}

Two operands that each have a type must have the same type. The operator converts neither of them:

pub fn mixed(a: I32, b: I64) -> I64 {
  a + b            // error: I32 and I64 do not mix
}

An integer literal never becomes a float: let f: F64 = 1 is a type mismatch. Write 1.0.

The range of a literal

The compiler checks each literal against its type:

  • An integer literal that does not fit is the error NumericOverflow. The lowest value of a type fits: -2147483648 is an I32, and -9223372036854775808 is an I64 where the position gives I64.
  • A float literal that does not fit is the error InvalidNumber. An F32 literal must fit the F32 range, so 3.5e38 is an error where the type is F32.
  • The digits of an F32 literal round one time, to the nearest F32. They do not round to an F64 first. A literal fits when that F32 is finite: 3.4028235e38 rounds to the largest F32, so it fits.
  • A literal with a fraction or an exponent cannot take an integer suffix, whatever its value: 3.5I32 and 1e3I32 are each the error InvalidNumber. Write 1000I32.
pub fn largest() -> F32 { 3.4028235e38F32 }   // the largest F32
pub fn up() -> F32 { 1.000000059604644775390625001F32 }

pub fn run_checks() {
  assert(condition: largest() > 3.4e38F32)
  assert(condition: up() == 1.0000001F32)   // the next F32 after 1.0
}
pub let lowest: I32 = -2147483648      // ok
pub let too_big: I32 = 2147483648      // error: does not fit in I32

A literal of a struct or an enum

A literal with no suffix can also take a struct or an enum type from its position. The type must have From<Literal<K>>, and the syntax of the literal fixes K:

SyntaxKExample
an integerI645 gives Literal(value: 5I64)
a fraction or an exponent(significand: I64, exponent: I32)1.5 gives (significand: 15I64, exponent: -1I32)
a stringString"abc" gives Literal(value: "abc")

The value of a fraction is significand × 10^exponent. The compiler reads it from the digits of the literal, and not from an F64, so it is exact: 0.1 is (1, -1), 1.50 is (150, -2) and -2.5e3 is (-25, 2). The literal then becomes T.from(value: Literal(value: ...)):

pub struct Price {
  cents: I64
}

impl From<Literal<I64>> for Price {
  fn from(value: Literal<I64>) -> Self { Price(cents: value.value * 100I64) }
}

impl From<Literal<(significand: I64, exponent: I32)>> for Price {
  fn from(value: Literal<(significand: I64, exponent: I32)>) -> Self {
    // Only two decimals: 12.5 is (125, -1), 12.50 is (1250, -2).
    let digits = value.value
    if digits.exponent == -1 {
      Price(cents: digits.significand * 10I64)
    } else {
      Price(cents: digits.significand)
    }
  }
}

fn total(p: Price) -> I64 { p.cents }

pub fn run_checks() {
  let a: Price = 12.5
  let b: Price = 3
  assert(condition: a.cents == 1250I64)
  assert(condition: b.cents == 300I64)
  assert(condition: total(p: 0.99) == 99I64)
}

Rules:

  • A position that expects T? also accepts TryFrom<Literal<K>>. A literal that the type refuses gives nil. A position that expects T does not accept TryFrom
  • A position that expects Literal<K> takes a literal of the matching syntax directly. A program can also build one: Literal(value: 5I64)
  • With no position that gives the type, call from: Price.from(value: 12.5). The parameter of from is a Literal<K>, so the literal is exact there too
  • An integer must fit in an I64, a significand in an I64 and an exponent in an I32. Otherwise the literal is an InvalidNumber or a NumericOverflow. A type checks its own limits in its from, when the program runs
  • A literal with a suffix, 1.5F64, keeps the type of its suffix
  • A type parameter converts a literal through its own bounds. The bound must name From<Literal<K>> with the K of the literal, or TryFrom<Literal<K>> in a T? position. The impls of a struct with the same name do not count, because the parameter hides the struct. Each call then gives the parameter its type:
fn zero<T: From<Literal<I64>>>() -> T {
  0
}

fn double<T: From<Literal<I64>> + Mul>(x: T) -> T {
  x * 2
}

pub fn run_checks() {
  let d = zero<Decimal>()
  let r = double(x: zero<Rational>())
  assert(condition: d.mantissa == 0I64)
  assert(condition: r.numerator == 0I64)
}

Decimal

The prelude declares Decimal, an exact decimal number. It is a struct with two fields: mantissa: I64 and scale: I32.

Its value is mantissa × 10^-scale: 1.5 is mantissa 15 and scale 1. A decimal keeps the trailing zeros of its literal, so 1.50 is mantissa 150 and scale 2. The scale goes from 0 to 28, as in the Rust crate rust_decimal. A decimal has no operations yet: a program writes it and gives it to a host function.

  • An integer literal always fits, so From<Literal<I64>> gives it: let d: Decimal = 42
  • A fraction can be out of range, so TryFrom gives it, and only a place that expects Decimal? takes one. A scale above 28, or a mantissa that does not fit in an I64, gives nil
extern fn greater_than(than: Decimal?) -> Boolean

pub fn query() -> Boolean {
  greater_than(than: 1.5)            // mantissa 15, scale 1
}

pub fn run_checks() {
  let whole: Decimal = 42
  let tiny: Decimal? = 1e-30         // nil: the scale is above 28
  assert(condition: whole.mantissa == 42I64 && whole.scale == 0)
  assert(condition: tiny.is_none())
}

Rational

The prelude declares Rational, an exact fraction. It is a struct with two fields: numerator: I64 and denominator: I64. A rational is reduced, and its sign is on the numerator, so its denominator is above 0. It has +, -, * and /: see Operators. A division by zero gives 0/0, a value that no other operation gives, so check the denominator.

A literal gives a rational as it gives a decimal: an integer through From, and a fraction through TryFrom, so a fraction needs a place that expects Rational?. 0.1 is 1/10, 1.5 and 1.50 are both 3/2, and a fraction whose 10^exponent does not fit in an I64 gives nil. A zero is 0/1 with any exponent.

No literal writes 1/3, but let r: Rational = 1 / 3 divides two rationals, so it is exact. Rational.of(numerator: 1I64, denominator: 3I64) also builds it. It reduces the fraction, and gives nil for a denominator of 0:

pub fn run_checks() {
  let half: Rational? = 0.5
  let third = Rational.of(numerator: 2I64, denominator: 6I64)
  assert(condition: match half { .some(r): r.numerator == 1I64 && r.denominator == 2I64, .none: false })
  assert(condition: match third { .some(r): r.denominator == 3I64, .none: false })
}

Overflow at run time

An integer operation whose result does not fit its type overflows. The language does not say what the result is: the backend decides. Integer division and remainder truncate toward zero. The ConstantFoldingPass computes each operation in the declared type. It leaves an operation that overflows, or that divides by zero, as it is, so the backend makes the same decision for it.

Never Type

Never is an uninhabited type: it has no values and cannot be instantiated. It is used as a return type for functions that diverge (infinite loops, panics):

extern fn abort() -> Never

Because Never has no value, it fits every type, as ! does in Rust. A branch of an if or an arm of a match of type Never gives way to the type of the other branches. A function body, a let value and an argument of type Never fit any declared type. The reverse is not true: a value of another type does not fit Never.

fn fail() -> Never { fail() }

pub fn positive(x: I32) -> I32 {
  if x > 0 { x } else { fail() }        // the type is I32
}

pub fn unwrap(o: I32?) -> I32 {
  match o { .some(n): n, .none: fail() }
}

pub fn run_checks() {
  assert(condition: positive(x: 5) == 5)
  assert(condition: unwrap(o: 3) == 3)
}

Array Types

Arrays hold multiple values of the same type:

pub struct User { name: String }

pub struct Collections {
  names: [String],             // Variable-length array of strings
  scores: [I32],               // Variable-length array of integers
  flags: [Boolean],            // Variable-length array of booleans
  matrix: [[I32]],             // Nested arrays
  users: [User]                // Array of custom types
}

// Array literals
pub let tags = ["urgent", "bug", "frontend"]
pub let numbers = [1, 2, 3, 4, 5]
pub let empty: [String] = []

// Array destructuring (see Expressions for full rules)
pub let [first, second] = ["a", "b", "c"]
pub let [user, ...] = ["John", "pass", "etc"]

pub fn run_checks() {
  assert(condition: numbers.len() == 5)
  assert(condition: second == "b")
  assert(condition: user == "John")
}

Optional Types

Optional types can be a value or nil:

pub struct User {
  name: String,
  email: String,
  nickname: String?,            // Optional field
  avatar: String?               // Optional field
}

pub let user1 = User(
  name: "Alice",
  email: "alice@example.com",
  nickname: "Ally",             // Provide a value
  avatar: nil                   // Explicitly nil
)

Optional<T> behaves like a built-in two-variant enum with .some(T) and .none. To consume the inner value, use the Rust-style if let form documented in Control Flow, or match against .some(x) / .none arms directly. Both branches are required when the result is used as a value, so the type-checker can unify them.

The language has one level of absence. An optional of an optional is one optional: Optional<I32?> is I32?. The same rule applies when a type parameter stands for an optional. If T is I32?, then T? is I32?, and the .some arm of a match on it binds an I32.

fn first_of<T>(xs: [T]) -> T? { xs[0] }

pub fn run_checks() {
  let os: [I32?] = [1, nil]
  let got: I32? = first_of(xs: os)     // I32?, not I32??
  let v = match got { .some(n): n, .none: 0 }
  assert(condition: v == 1)
}

Dictionary Types

Key-value mappings using bracket syntax with colon:

pub struct User { id: I32, name: String }

pub struct AppConfig {
  settings: [String: I32],         // String keys to I32 values
  scores: [I32: String],           // I32 keys to String values
  cache: [String: User]            // String keys to custom types
}

// Dictionary literals (string keys must be quoted)
pub let settings: [String: I32] = ["timeout": 30, "maxRetries": 3]
pub let scores: [I32: String] = [100: "perfect", 95: "excellent"]
pub let empty: [String: Boolean] = [:]

// A dictionary made from data: see `collect` in Control Flow
pub fn by_id(users: [User]) -> [I32: User] {
  for u in users { u }.collect(key: (u) -> u.id, value: (u) -> u)
}

pub fn run_checks() {
  let found = by_id(users: [User(id: 7, name: "Ada")])
  assert(condition: found.len() == 1)
  assert(condition: settings["timeout"] != nil)
  let bands = [0..3: "low", 3..5: "high"]    // a range is a key
  assert(condition: bands[0..3] != nil)
  assert(condition: bands[0..4] == nil)
}

Rules:

  • Keys can be String, I32, I64, Boolean, a range, a struct, or an enum. A struct or an enum is a key only when each of its fields is a key type too. For a generic struct or enum, a field of type T has the type of the type argument, so Box<I32> is a key and Box<F64> is not
  • A range is a key when its bounds are keys: 0..3 is a key. Two ranges are one key when their starts are equal and their ends are equal
  • Any other key type is the error E134 (InvalidDictionaryKey): a float, an optional, an array, a tuple, a dictionary, a closure, or a struct or enum that holds one of them
  • F32 and F64 cannot be keys. A float has no usable equality: NaN is not equal to itself, so a key can never be found again, and 0.0 equals -0.0, so two distinct-looking keys collide. The rule also applies to a key type that the compiler infers: the keys of a literal, the key closure of collect, and a generic call
  • A lookup d[k] needs a key of the key type. A key of another type, an optional key included, is a TypeMismatch
  • A repeated key makes one entry. The later value replaces the earlier one: ["a": 1, "a": 2] holds one entry, "a": 2
  • String keys must be quoted in literals: ["key": value]
  • Numeric keys are unquoted: [42: value]
  • Empty dict: [:]
  • No destructuring support for dictionaries

Tuples

Named tuples group related values with field names:

pub struct Config {
  person: (name: String, age: I32),
  point: (x: I32, y: I32),
  nested: (user: (first: String, last: String), active: Boolean)
}

pub fn run_checks() {
  // Tuple literals
  let person = (name: "John", age: 30)
  let point = (x: 10, y: 20)
  let nested = (user: (first: "John", last: "Doe"), active: true)

  // Accessing tuple fields by name
  assert(condition: person.name == "John")
  assert(condition: point.x + point.y == 30)
  assert(condition: nested.user.last == "Doe")
}

Rules:

  • Tuples use parentheses: (name: value, ...)
  • All fields must be named (no positional tuples)
  • Access fields with dot notation: tuple.fieldName
  • Trailing comma allowed: (x: 1, y: 2,)
  • Tuples can be nested

Closure Types

Closure types define function signatures for callbacks and transformations. The parameter list is always parenthesised, even for a single parameter, so every -> in the language is preceded by ). For closure expressions, see Closures.

struct Controls<E> {
  // No parameters - returns E
  onPress: () -> E,

  // Single parameter (default / let convention)
  onChange: (String) -> E,

  // Multiple parameters
  onResize: (I32, I32) -> E,

  // mut parameter: caller must pass a mutable binding
  onScale: (mut I32) -> E,

  // sink parameter: caller's binding is consumed (moved)
  onSubmit: (sink String) -> E,

  // Optional closure (can be nil)
  onFocus: ((String) -> E)?,

  // Closure returning optional
  validate: (String) -> Boolean?
}

Type syntax:

ParametersSyntaxExample
None() -> T() -> Event
One (default)(T) -> U(String) -> Event
One (mut)(mut T) -> U(mut I32) -> Event
One (sink)(sink T) -> U(sink String) -> Event
Multiple(T, U) -> V(I32, I32) -> Point
Mixed conventions(mut T, sink U) -> V(mut I32, sink String) -> V

Rules:

  • The parameter list is always parenthesised — even with one parameter — and every -> in the language is preceded by ).
  • Multiple parameters are comma-separated inside the parens.
  • Convention keywords (mut, sink) precede the type in the type position.

Generic Types

Types parameterized with type variables (full details in Generics):

pub trait Layout { width: I32 }
pub trait Render { fn render(self) -> Boolean }
pub trait Click { fn click(self) -> Boolean }

pub struct Box<T> { value: T }                        // Single type parameter
pub struct Pair<A, B> { first: A, second: B }         // Multiple type parameters
pub struct Container<T: Layout> { items: [T] }        // With trait constraint
pub struct Widget<T: Render + Click> { component: T } // Multiple trait constraints

pub enum Result<T, E> { ok(value: T), error(err: E) }
pub let answer: Result<String, I32> = .ok(value: "yes") // Instantiated generic

Indexing

Three types take an index: an array by position, a dictionary by key, and a string by byte offset. Nothing else does.

pub fn run_checks() {
  let xs = [10, 20]
  let a: I32? = xs[1]      // the position may be out of range
  let d = ["k": 5]
  let b: I32? = d["k"]     // the key may be absent
  let c: I32 = "abc"[0]    // the byte at that offset

  assert(condition: if let v = a { v == 20 } else { false })
  assert(condition: xs[2] == nil)
  assert(condition: if let v = b { v == 5 } else { false })
  assert(condition: c == 97)
}

Any other type is an error:

pub struct Point { x: I32 }

pub fn f() -> I32 {
  let p = Point(x: 1)
  p[0]                     // error E139: a struct has no index operation
}

The type of the index is checked too. An array and a string take an I32 position, and a dictionary takes a key of its key type. Any other index type is a TypeMismatch.

An array index and a dictionary lookup both produce an optional, because the position may be out of range and the key may be absent. When the elements are optional, the index gives one optional, not an optional of an optional. See Expressions / Indexing.

Immutable Elements

The three types that take an index never change after you make them. The language has no write through an index. One rule covers all three:

pub fn f() {
  let mut xs: [I32] = [1, 2, 3]
  xs[0] = 9                  // error E143: cannot assign to an element

  let mut d: [String: I32] = ["a": 1]
  d["a"] = 9                 // error E143

  let mut s: String = "ab"
  s[0] = 65                  // error E143
}

let mut does not change this. It lets you assign the binding, and it says nothing about the contents. A field above the index makes no difference either: h.xs[0] = 9 reports the same error, because the write still passes through the index.

Three kinds of write are legal. You assign a whole binding, you assign a struct field, and you pass a mut parameter:

pub struct Point { x: I32 }

pub fn run_checks() {
  let mut xs: [I32] = [1, 2]
  xs = [3, 4, 5]             // ok: the binding takes a new array

  let mut p: Point = Point(x: 1)
  p.x = 9                    // ok: a field, through a mut binding

  assert(condition: xs.len() == 3)
  assert(condition: p.x == 9)
}

To change the elements, make a new value. A for pipeline builds one in a single pass:

pub fn run_checks() {
  let mut xs: [I32] = [1, 2, 3]
  xs = for x in xs { x * 2 }.collect()
  assert(condition: for x in xs { x }.fold(initial: 0, f: (a, b) -> a + b) == 12)
}

A host can still offer mutation. The signature shows it, and the runtime does the work:

extern fn push(mut xs: [I32], value: I32)

Why the elements are immutable

Three properties depend on the rule.

  • A string slice is zero-copy. slice shares the memory of the source. A write to a string would therefore reach every slice that the source made.
  • The language uses Mutable Value Semantics. An element write makes let ys = xs need a copy. The copy would happen where nobody asked for it. See Functions.
  • A for yields a lazy sequence. Nothing runs until a terminal combinator consumes it. If the source could change in between, the result of a pipeline would depend on evaluation order. See Large Data.

Optional Elements

A value wraps into an optional wherever one is declared, and that reaches inside a container: [T] fits [T?] the same way T fits T?.

Whether the optional is worth declaring is a separate question. A plain let has no later, so if every element of the literal is present, the optional says more than the value means:

let a: [I32?] = [nil, 3]      // ok: one element really is absent
let mut b: [I32?] = [3]       // ok: a nil may be put there later
let c: [I32?] = [3]           // error E142: declare it [I32]
let d: [I32?] = []            // ok: an empty literal claims nothing

The same holds for a dictionary's values.