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

TypeDescriptionExamples
numNumbers (integers and floats)42, 3.14, -7
boolBoolean valuestrue, false
stringText strings"hello", 'world'

Compound Types

TypeDescriptionExamples
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)
complexComplex numberscomplex(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

FeatureRecord {x: T}Dictionary dict<V>
KeysFixed field namesDynamic string keys
ValuesDifferent types per fieldSame type for all values
Accessobj.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

  1. At definition time: The compiler validates that the converter function exists and returns the target type
  2. At call time: If an argument doesn't match the target type, the compiler checks if the converter can accept it
  3. 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 string and num literals are supported (not bool)
  • Mixed types in a union are not allowed: "on" | 1 is 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

CategoryTypes
Primitivesnum, bool, string
Literals"on", "on" | "off", 0 | 1 | 2
Unionsnum | string, A | B | C
EnumsShape = Circle | Rect
Collectionsarray<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