Types in Stone
Stone is statically typed with full type resolution. Types are determined at compile time, and type annotations are optional—the compiler resolves types from usage when annotations are omitted.
Primitive Types
| Type | Description | Examples |
|---|---|---|
num | Numbers (integers and floats) | 42, 3.14, -7 |
bool | Boolean values | true, false |
string | Text strings | "hello", 'world' |
Compound Types
| Type | Description | Examples |
|---|---|---|
array<T, R> | Dense typed array (rank R) | [[1,2],[3,4]] |
{...} | Record (key-value) | {x = 1, y = 2} |
dict<V> | Dictionary (string keys) | dict(keys, vals) |
complex | Complex numbers | complex(3, 4) |
Arrays
Arrays are the primary collection type in Stone. They are typed, dense, and support multi-dimensional data.
Array Literals
vec = [1, 2, 3] // array<num, 1>
mat = [[1, 2], [3, 4]] // array<num, 2>
strs = ["a", "b", "c"] // array<string, 1>
Array Constructors
import zeros, ones, eye from linalg
A = zeros(3, 3) // 3x3 array of zeros
B = ones(4) // 1D array of ones
I = eye(4) // 4x4 identity matrix
Complex Numbers
Complex numbers in Stone are represented as records with re (real) and im (imaginary) fields:
import complex, abs from complex
z = complex(3, 4) // {re = 3, im = 4}
w = complex(5) // {re = 5, im = 0}
z.re // 3 (direct field access)
z.im // 4
abs(z) // 5 (magnitude)
// Standard operators work with complex numbers
z + w // {re = 8, im = 4}
z * w // {re = 15, im = 20}
z - w // {re = -2, im = 4}
z / w // {re = 0.6, im = 0.8}
Complex Arrays
import czeros, ceye from complex
C = czeros(3, 3) // 3x3 complex zero matrix
I = ceye(2) // 2x2 complex identity
See Complex Module for full documentation.
Record Types
Records use structural typing—they match based on their fields, not their names. Stone uses width subtyping: a record with extra fields satisfies a type that requires fewer fields.
Record Literals
point = {x = 10, y = 20} // resolved: {x: num, y: num}
person = {name = "Alice", age = 30}
Field Access
point.x // 10
person.name // "Alice"
Structural Subtyping (Width Subtyping)
A record with more fields can be passed where a narrower type is expected:
fn get_x(obj: {x: num}) = obj.x
point = {x = 10, y = 20, z = 30}
get_x(point) // OK — point has x, extra fields are allowed
Missing required fields are always an error:
fn need_both(obj: {x: num, y: num}) = obj.x + obj.y
need_both({x = 10}) // ERROR: Missing field: y
Extracting Assignment
The := operator extracts fields by name:
x, y := point // x = point.x, y = point.y
// From loop results
result := evolve { sum = 0, count = 0 } for (x in items) {
sum' = sum + x
count' = count + 1
}
// result.sum and result.count available
Empty Records
Empty records {} are valid and represent records with no fields. They are useful as default values in schemas.
Records as Type Schemas
Records can be used as type schemas to define structured data with type annotations and default values. This is particularly useful when parsing external data.
Defining a Schema
A schema record uses type annotations (:) for field types and optional default values (=):
// Schema with required and optional fields
User = {
id: num, // Required - no default
name: string, // Required - no default
role: string = "guest" // Optional - has default value
}
Creating Instances from Schemas
Use the schema as a type annotation when creating instances:
// Create instance - must provide required fields
admin: User = {id = 1, name = "Alice", role = "admin"}
// Default value applied for optional field
guest: User = {id = 2, name = "Bob"}
// guest.role is "guest" (default applied)
Schema Validation
The type system enforces schema constraints:
User = {id: num, name: string, email: string = "unknown"}
// OK - all required fields provided
valid: User = {id = 1, name = "Alice"}
// ERROR - missing required field 'name'
invalid: User = {id = 1}
// OK - can override default
custom: User = {id = 1, name = "Bob", email = "[email protected]"}
Accessing Schema Fields
- Fields with default values can be accessed on the schema itself
- Required fields (no default) cannot be accessed on the schema—only on instances
Config = {version: num, debug: bool = false}
print(Config.debug) // OK - "false" (default value)
print(Config.version) // ERROR - required field has no default
// Create instance to access all fields
cfg: Config = {version = 2}
print(cfg.version) // OK - "2"
print(cfg.debug) // OK - "false"
Using Schemas with parse()
Schemas work seamlessly with the parse() function to validate external data:
// Define expected structure
User = {id: num, name: string, role: string = "user"}
// Parse JSON data against schema
json = '{"id": 1, "name": "Alice"}'
user = parse(json, User)
print(user.name) // "Alice"
print(user.role) // "user" (default applied)
See Network Functions for details on http.request and parse.
Dictionary Types
Dictionaries map string keys to values with uniform types.
Dictionary Syntax
// Type annotation: dict<ValueType>
scores: dict<num> = {"math": 95, "english": 88}
// String keys in literals (must be quoted)
grades: dict<string> = {"Alice": "A", "Bob": "B"}
Dictionary Access
Dictionaries use dot access for known fields and get() for dynamic lookup:
scores.math // 95
grades.Alice // "A"
// Dynamic lookup with get()
result = get(scores, "math")
if (result.ok) { print(result.value) }
Dictionary vs Record
| Feature | Record {x: T} | Dictionary dict<V> |
|---|---|---|
| Keys | Fixed field names | Dynamic string keys |
| Values | Different types per field | Same type for all values |
| Access | obj.field or get(obj, key) | obj.field or get(obj, key) |
| Type | {name: string, age: num} | dict<num> |
Dictionary-Record Unification
A dictionary can unify with a record if all values match:
data: dict<num> = {"x": 1, "y": 2}
// Can be used where {x: num, y: num} is expected
Type Annotations (Optional)
Stone supports optional type annotations. When provided, they are enforced at compile time:
x: num = 42
name: string = "Alice"
fn add(a: num, b: num) -> num = a + b
When Annotations Are Required
While type resolution handles most cases, there are situations where annotations help the compiler:
Array Rank in Loop Accumulators
When building a 2D array by pushing rows in a loop, the compiler may not resolve the correct rank:
// Without annotation - may resolve rank 1 instead of rank 2
rows = []
result = evolve { rows = rows, i = 0 } while (i < n) {
row = matrix[i] // Extract row from 2D matrix
rows' = push(rows, row) // Push 1D row into accumulator
i' = i + 1
}
// rows ends up as array<array<num,1>,1> instead of array<num,2>
// With annotation - explicitly declare the intended rank
init_rows: array<num, 2> = []
result = evolve { rows = init_rows, i = 0 } while (i < n) {
row = matrix[i]
rows' = push(rows, row)
i' = i + 1
}
// rows correctly typed as array<num,2>
This happens because the empty array [] is initialized before any rows are pushed, and the compiler can't predict the final structure. The annotation array<num, 2> tells the compiler to expect a 2D matrix.
Type Aliases
Define reusable type names:
Vec3 = array<num, 1>
Matrix = array<num, 2>
Person = {name: string, age: num}
Function Types
Functions are first-class values with resolved types:
fn square(x) = x * x
// Functions can be passed as arguments
fn apply(f, x) = f(x)
result = apply(square, 5) // 25
Polymorphic Functions
Some built-in functions like print accept any type. Each call site gets fresh type variables:
print("hello") // accepts string
print([1, 2, 3]) // accepts array<num, 1>
print(42) // accepts num
Overloaded Functions
A function can work on different argument types by combining several
implementations into one overloaded name. Each implementation is a normal
function with its own name; you join them into an overload set with |:
fn area_circle(c: Circle) -> num = 3.14159 * c.r * c.r
fn area_rect(r: Rect) -> num = r.w * r.h
fn area = area_circle | area_rect // the overload set
area({r = 2}) // dispatches to area_circle
area({w = 3, h = 4}) // dispatches to area_rect
The set is the public name; the arms are usually left unexported. Members may also be module-qualified, which is how a barrel combines arms from several submodules:
import format/json as json
import format/csv as csv
fn parse = json.parse | csv.parse
The compiler picks the matching arm from the argument types. Standard-library
names are built the same way — sqrt handles real and complex, find works on
strings and arrays, dot covers vectors and matrices:
sqrt(4) // real: 2
sqrt(-1 + 0i) // complex: 0 + 1i
find("hello", "ll") // string search: 2
find([10, 20, 30], 20) // array search: 1
When no arm matches the argument types, the compiler reports an error listing the available arms:
dot(M, M) // Error: no overload of dot accepts these arguments
// Available: dot(array<num,1>, array<num,1>) -> num
// dot(array<num,2>, array<num,1>) -> array<num,1>
Overloading is explicit-only. Defining the same name twice with a bare fn is
a duplicate-definition error — give each implementation a distinct name and
combine them with fn name = a | b. Two arms with identical parameter types
are rejected as well, since no call could choose between them.
Type Promotion
Type promotion allows functions to accept alternative types that can be converted to the expected type. The conversion function is specified directly in the parameter annotation.
Promotion Syntax
param: TargetType | converter(param)
This means: "accept TargetType directly, or accept any type that converter can convert to TargetType".
Example: Complex Numbers
The complex module uses promotion to allow mixing num and complex arguments:
// Complex is a record type
Complex = {re: num, im: num}
// Converter function: num -> Complex
fn to_complex(x: num) -> Complex = {re = x, im = 0}
// Arithmetic with promotion
fn add(a: Complex | to_complex(a), b: Complex | to_complex(b)) -> Complex =
{re = a.re + b.re, im = a.im + b.im}
With this definition, all these calls work:
z1 = complex(1, 2)
z2 = complex(3, 4)
add(z1, z2) // Both Complex - no conversion needed
add(5, z1) // 5 promoted via to_complex(5) -> {re = 5, im = 0}
add(z1, 3) // 3 promoted via to_complex(3) -> {re = 3, im = 0}
add(2, 3) // Both promoted - returns {re = 5, im = 0}
How Promotion Works
- At definition time: The compiler validates that the converter function exists and returns the target type
- At call time: If an argument doesn't match the target type, the compiler checks if the converter can accept it
- At runtime: The converter is called automatically before the function body executes
Defining Custom Promotions
You can define promotion for your own types:
// A 2D vector type
Vec2 = {x: num, y: num}
// Converter: scalar -> Vec2 (broadcast)
fn to_vec2(s: num) -> Vec2 = {x = s, y = s}
// Vector addition with scalar promotion
fn add_vec(a: Vec2 | to_vec2(a), b: Vec2 | to_vec2(b)) -> Vec2 =
{x = a.x + b.x, y = a.y + b.y}
// Now these all work:
v1 = {x = 1, y = 2}
add_vec(v1, {x = 3, y = 4}) // Vec2 + Vec2
add_vec(v1, 5) // Vec2 + scalar (5 becomes {x = 5, y = 5})
add_vec(2, 3) // scalar + scalar
Promotion vs Manual Conversion
Without promotion, you would need to convert arguments manually:
// Without promotion - verbose
add(to_complex(5), z1)
add(z1, to_complex(3))
// With promotion - cleaner
add(5, z1)
add(z1, 3)
Multiple Converters
You can specify multiple converters for a single parameter. The compiler ensures each converter accepts a distinct source type, so exactly one converter matches at call time:
Vec2 = {x: num, y: num}
// Converter 1: scalar to Vec2 (broadcast)
fn from_scalar(n: num) -> Vec2 = {x = n, y = n}
// Converter 2: array to Vec2
fn from_array(a: array<num, 1>) -> Vec2 = {x = a[0], y = a[1]}
// Function with multiple converters
fn vec_length(v: Vec2 | from_scalar(v) | from_array(v)) -> num = sqrt(v.x * v.x + v.y * v.y)
// All these work:
vec_length({x = 3, y = 4}) // Direct Vec2: 5
vec_length(5) // from_scalar: sqrt(50)
vec_length([3, 4]) // from_array: 5
Overlapping Converters: If two converters accept the same source type, the compiler reports an error:
// ERROR: Overlapping source types
fn bad(v: Vec2 | from_num1(v) | from_num2(v)) = v
// Both from_num1 and from_num2 accept num
Limitations
- The converter must be a single-argument function
- Promotion only applies to function parameters, not return types
- Overlapping source types across converters cause compile-time errors
Literal Types
Literal types constrain a value to specific literal values, checked at compile time.
Single Literal Constraint
fn is_on(mode: "on") -> bool = true
is_on("on") // OK
is_on("off") // ERROR: expected "on", got "off"
Literal Union Types
Use | to accept one of several literal values:
fn set_mode(m: "on" | "off") -> string = if (m == "on") { "activated" } else { "deactivated" }
set_mode("on") // OK: "activated"
set_mode("off") // OK: "deactivated"
set_mode("maybe") // ERROR: expected "on" | "off", got "maybe"
Numeric Literal Types
Numbers work the same way:
fn get_priority(level: 0 | 1 | 2) -> string =
if (level == 0) { "low" }
elif (level == 1) { "medium" }
else { "high" }
get_priority(0) // OK: "low"
get_priority(1) // OK: "medium"
get_priority(5) // ERROR: expected 0 | 1 | 2, got 5
Negative numbers are supported:
fn get_sign(n: -1 | 0 | 1) -> string =
if (n == -1) { "negative" }
elif (n == 0) { "zero" }
else { "positive" }
Literal Types in Return Position
Functions can return literal types, enabling type-safe value propagation:
fn get_default_mode() -> "on" = "on"
fn choose_mode(flag: bool) -> "on" | "off" = if (flag) { "on" } else { "off" }
// Return types flow through
set_mode(get_default_mode()) // OK: return type "on" satisfies "on" | "off"
set_mode(choose_mode(true)) // OK: return type "on" | "off" matches
Compile-Time Enforcement
Literal types are enforced through the type system, not runtime checks:
// OK - literal "on" has type "on", which is in "on" | "off"
set_mode("on")
// OK - function returns literal type "on"
mode = get_default_mode()
set_mode(mode)
// ERROR - general string type cannot satisfy literal constraint
s: string = "on"
set_mode(s) // ERROR: expected "on" | "off", got string
A general string or num type cannot satisfy a literal constraint, even if the runtime value would be valid. The type must be provably one of the allowed literals.
Limitations
- Only
stringandnumliterals are supported (notbool) - Mixed types in a union are not allowed:
"on" | 1is an error - Literal unions must have at least one value
Union Types
Union types allow a value to be one of several types. Use | to create a union:
Basic Union Types
// Function accepting num or string
fn stringify(x: num | string) -> string = str(x)
stringify(42) // "42"
stringify("hello") // "hello"
stringify(true) // ERROR: expected num | string, got bool
Union Types in Return Position
fn parse_value(s: string) -> num | string = if (s == "unknown") { s } else { num(s) }
Arrays of Union Types
// Array where each element is one of the union members
fn process(items: array<num | string, 1>) -> num = len(items)
process([1, "two", 3, "four"]) // 4
Enum Schemas
Enum schemas define named union types from record schemas. They work like record schemas but for unions of types.
Defining an Enum Schema
Use = with | to define an enum schema from existing record schemas:
// Define record schemas for each variant
Circle = {kind: string = "circle", radius: num}
Rect = {kind: string = "rect", width: num, height: num}
Line = {kind: string = "line", length: num}
// Define enum schema as union of variants
Shape = Circle | Rect | Line
Using Enum Schemas as Types
Enum schemas can be used as type annotations:
// Function accepting any Shape variant
fn get_kind(s: Shape) -> string = s.kind
// All variants are accepted
get_kind({kind = "circle", radius = 10}) // "circle"
get_kind({kind = "rect", width = 5, height = 3}) // "rect"
get_kind({kind = "line", length = 20}) // "line"
Arrays of Enum Types
Shape = Circle | Rect
fn count_shapes(shapes: array<Shape, 1>) -> num = len(shapes)
shapes = [
{kind = "circle", radius = 5},
{kind = "rect", width = 10, height = 20}
]
count_shapes(shapes) // 2
Returning Enum Types
Cat = {species: string = "cat", name: string}
Dog = {species: string = "dog", name: string}
Pet = Cat | Dog
fn make_pet(is_cat: bool, name: string) -> Pet =
if (is_cat) { {species = "cat", name = name} }
else { {species = "dog", name = name} }
pet = make_pet(true, "Whiskers")
print(pet.name) // "Whiskers"
print(pet.species) // "cat"
Accessing Shared Fields
When accessing fields on a union/enum type, only fields that exist on all variants can be accessed:
Circle = {kind: string, radius: num}
Rect = {kind: string, width: num, height: num}
Shape = Circle | Rect
fn get_kind(s: Shape) -> string = s.kind // OK - 'kind' exists on all
fn get_radius(s: Shape) -> num = s.radius // ERROR - 'radius' only on Circle
Enum Schema Runtime Value
At runtime, an enum schema is an object with metadata:
Shape = Circle | Rect | Line
// Shape is: { kind: "enum", types: [Circle, Rect, Line] }
This enables future features like runtime type checking and parse() integration.
Type Conversions
Convert between types with built-in functions:
num("3.14") // 3.14
int("42") // 42
str(123) // "123"
bool(1) // true
bool(0) // false
Type Reflection
type(42) // "num"
type("hello") // "string"
type([1,2,3]) // "array"
type({x = 1}) // "record"
import is_complex, is_complex_array from complex
is_complex(z) // true if complex number (has re and im fields)
Summary
| Category | Types |
|---|---|
| Primitives | num, bool, string |
| Literals | "on", "on" | "off", 0 | 1 | 2 |
| Unions | num | string, A | B | C |
| Enums | Shape = Circle | Rect |
| Collections | array<T, R>, {...}, dict<V> |
| Complex | {re: num, im: num} (record type) |
| Functions | (T1, T2, ...) -> R |
Stone's type system is:
- Static — types checked at compile time; type errors block compilation
- Resolved — annotations optional, types derived from usage
- Structural — records match by fields, not names; width subtyping allows extra fields
- Sound — type errors caught before execution
- Polymorphic — functions can accept multiple types via instantiation
- Literal-aware — specific values can be constrained at compile time
- Union-aware — values can be one of several types