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.
| Type | Range / shape |
|---|---|
I32 | 32-bit signed integer |
I64 | 64-bit signed integer |
F32 | 32-bit IEEE 754 float |
F64 | 64-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:-2147483648is anI32, and-9223372036854775808is anI64where the position givesI64. - A float literal that does not fit is the error
InvalidNumber. AnF32literal must fit theF32range, so3.5e38is an error where the type isF32. - The digits of an
F32literal round one time, to the nearestF32. They do not round to anF64first. A literal fits when thatF32is finite:3.4028235e38rounds to the largestF32, so it fits. - A literal with a fraction or an exponent cannot take an integer
suffix, whatever its value:
3.5I32and1e3I32are each the errorInvalidNumber. Write1000I32.
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:
| Syntax | K | Example |
|---|---|---|
| an integer | I64 | 5 gives Literal(value: 5I64) |
| a fraction or an exponent | (significand: I64, exponent: I32) | 1.5 gives (significand: 15I64, exponent: -1I32) |
| a string | String | "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 acceptsTryFrom<Literal<K>>. A literal that the type refuses givesnil. A position that expectsTdoes not acceptTryFrom - 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 offromis aLiteral<K>, so the literal is exact there too - An integer must fit in an
I64, a significand in anI64and an exponent in anI32. Otherwise the literal is anInvalidNumberor aNumericOverflow. A type checks its own limits in itsfrom, 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 theKof the literal, orTryFrom<Literal<K>>in aT?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
TryFromgives it, and only a place that expectsDecimal?takes one. A scale above 28, or a mantissa that does not fit in anI64, givesnil
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 typeThas the type of the type argument, soBox<I32>is a key andBox<F64>is not - A range is a key when its bounds are keys:
0..3is 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 F32andF64cannot be keys. A float has no usable equality:NaNis not equal to itself, so a key can never be found again, and0.0equals-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, thekeyclosure ofcollect, 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 aTypeMismatch - 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:
| Parameters | Syntax | Example |
|---|---|---|
| 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.
sliceshares 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 = xsneed a copy. The copy would happen where nobody asked for it. See Functions. - A
foryields 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.