The Scaly Programming Language

Chapter 4

Type System

Overview

Scaly currently recognizes a fixed set of primitive types — integers, floats, booleans, and pointer types — which the LLVM-based emitter maps to the corresponding LLVM types. A possible future direction, described in the following sections, is to make the type system fully backend-agnostic, with even the primitive types supplied by a backend-specific prelude rather than built into the compiler.

Intrinsic Types (Future Direction)

An intrinsic type is a type whose implementation is provided by the backend, not by Scaly code. Under this design, intrinsic types would be declared using the intrinsic keyword:


define i32 intrinsic
define i64 intrinsic
define f64 intrinsic
    

The compiler would not interpret these definitions — it would simply record that these types exist and are intrinsic. The backend (Emitter) would be responsible for mapping intrinsic types to the target platform's representation.

This design would enable Scaly to target any backend:

  • LLVM: i32, i64, f32, f64

  • JavaScript: number, string, boolean

  • WebAssembly: i32, i64, f32, f64

  • JVM: int, long, float, double

Type Aliases (Future Direction)

Type aliases provide human-friendly names for intrinsic or compound types:


define bool i1
define char i32       ; Unicode scalar value
define int i64        ; Platform word size
define size_t u64
    

The alias and its target are interchangeable — bool and i1 refer to the same type.

Backend Preludes (Future Direction)

Each backend provides a prelude file that defines the intrinsic types and their aliases for that target. The prelude is implicitly loaded before any user code.

LLVM Prelude (excerpt)

; Intrinsic types (LLVM native)
define i1 intrinsic
define i8 intrinsic
define i16 intrinsic
define i32 intrinsic
define i64 intrinsic
define f32 intrinsic
define f64 intrinsic
define ptr intrinsic

; Human-friendly aliases
define bool i1
define char i32
define int i64
define size_t u64
      
JavaScript Prelude (excerpt)

; Intrinsic types (JavaScript native)
define number intrinsic
define string intrinsic
define boolean intrinsic

; Aliases for compatibility
define int number
define float number
define bool boolean
      

Literal Type Inference

Numeric and other literals do not have an inherent type. Their type is inferred from context — the parameter a literal is passed to, the annotation on its binding, or the other operand of an operation:


function double(x: i32) returns i32 { x * 2 }

double(42)           ; 42 inferred as i32 from parameter type

let y: i64 100       ; 100 inferred as i64 from annotation
let z y + 50         ; 50 inferred as i64 to match y
    

When nothing constrains an integer literal, it defaults to int (the platform word size), so the common case needs no annotation:


let x 42             ; x is int
    

An integer constant — a literal, a define declared int, and an operation over such — keeps its exact value until it meets the type it is used as, and then it must fit that type. A constant that does not fit is a compile-time error, never a value cut to the width:


let a: u8 200 + 55   ; 255
let b: u8 300        ; error: the constant 300 does not fit u8
let c: u32 0 - 1     ; error: the constant -1 does not fit u32
let d: u8 ~0         ; 255: the complement in the target's width
    

Scaly does not support type suffixes on literals (such as 42i32). Where a different width matters, annotate the binding (let x: i32 42) or cast (42 as i32).

Tuples and the Empty Tuple

A tuple is an anonymous record, written in parentheses: (1, "one"), or with labels, (x: 1, y: 2). Its type is written the same way, (int, String), and it may stand wherever a type may: as a parameter, a result, a field or a generic argument. A labelled component is read by its label, t.x; an unlabelled one is reached by decomposition, let (a, b) t. A routine returns several values as a tuple:


function divide(n: int, d: int) returns (int, int)
    (n / d, n % d)

let (q, r) divide(17, 5)     ; q is 3, r is 2
    

A parenthesized single value is that value, so (a) is a. The empty tuple () is the type of no value: a routine that answers nothing returns (), which the word void also names. It holds no bytes, so Result[(), Error] or Vector[()] cost nothing beyond their other parts. A call that answers () where a type is declared is a compile-time error.

References

By default, values flow through functions by value: a function parameter gets read-only access to its argument, and a function returns a value.

To refer to an existing value without copying it, Scaly provides the reference type ref[T] — a non-owning, borrowed reference to a T. References are used for parameters and for nullable fields, written ref[T]?. Like pointer[T], a reference is represented as a machine pointer, but it carries borrow semantics rather than the unchecked, low-level meaning of a raw pointer.

Low-Level Pointers

For low-level implementation of data structures like Page, HashMap, or List, the type pointer[T] is available. This is an escape hatch for implementors, not for everyday code.


; Low-level list node (internal implementation)
define Node[T]
(
    data: T
    next: pointer[Node[T]]    ; raw pointer for linked structure
)
      

Nullable Values

For nullable values, use Option[T] from the standard library — a proper sum type with Some(value) and None variants, matched with choose:


let o Option[int].Some(7)
choose o
    when v: Some
        print "got a value"
    else
        print "empty"
      

For many element types the compiler optimizes Option[T] to a simple nullable pointer — no space overhead for the tag (see Option Optimization).

Parameter Passing

All function parameters are borrowed — functions receive read-only access to their arguments. The caller retains ownership.


define Point ( x: int, y: int )

function distance_squared(a: Point, b: Point) returns int
{
    let dx a.x - b.x
    let dy a.y - b.y
    dx * dx + dy * dy
}

let origin Point(0, 0)
let target Point(3, 4)
distance_squared(origin, target)    ; origin and target unchanged
    

Implementation

The implementation of parameter passing depends on the execution context, but the semantics remain identical:

  • Same thread/stack: A pointer is passed. No copying occurs. The function reads through the pointer.

  • Different thread/GPU/remote: The entire data tree is copied to the target execution context. The function still has read-only access — same semantics, different mechanism.

This design means code doesn't change based on where it executes. A function that works locally works identically when distributed.

Mutation via Procedures

To modify data, use a procedure instead of a function. A procedure's parameters may be mutated, and the caller sees the change — no extra keyword is needed; the procedure declaration itself is the signal:


define Point ( x: int, y: int )

procedure move(p: Point, dx: int)
    p.x := p.x + dx

var pos Point(0, 0)
move(pos, 5)          ; pos.x is now 5
      

The distinction between functions (pure, read-only) and procedures (may mutate) is explicit in the code. Readers immediately know which calls might have side effects.

Constructors

Constructors create instances of types. Scaly provides both implicit and explicit constructors.

Implicit Constructors

If all members of a type are public, an implicit constructor is generated that takes all fields as parameters in declaration order:


define Point ( x: int, y: int )

let p Point(10, 20)    ; implicit constructor
      

Explicit Constructors

Use init for explicit constructors when you need default values for some fields or different construction signatures. An explicit init coexists with the implicit all-fields constructor:


define Point
(
    x: int
    y: int
)
{
    init(value: int)        ; convenience constructor
    {
        this.x := value
        this.y := value
    }
}

let p1 Point(10, 20)    ; implicit constructor
let p2 Point(5)         ; convenience init - Point(5, 5)
      

The this Prefix

The this. prefix is optional when unambiguous, but recommended for clarity:


init(x: int, y: int) {
    this.x := x    ; clear: field x gets parameter x
    this.y := y
}
      

When parameter names shadow field names, this. is required to disambiguate. Avoid x := x — it's confusing even if technically resolvable.

Complete Initialization

Every constructor should initialize all fields. The compiler does not yet enforce this: a field an init forgets to set contains an undefined value, not an implicit default (no automatic 0, false, or null). Enforcement as a compile-time error is planned; until then, treat a missing assignment in an init as a bug.

Constructor Return

init implicitly returns this, enabling direct binding:


let p Point(10, 20)    ; init returns the new Point
      

Types That Allocate

Some types are value types (the struct itself can live on the stack) but allocate internal data — String, Array, and the other containers. Their constructors are ordinary init definitions; the region their internal data is allocated in is chosen by the compiler from how the constructed value is used (see Lifetimes and Memory Regions). No special constructor syntax is involved.

Lambdas and Closures

A lambda is an anonymous function defined with backslash syntax — parameters, a colon, and a body:


\x: x * 2                    ; single parameter
\x y: x + y                  ; multiple parameters
    

A lambda bound with let is called like a named function. Its parameter types are inferred at each call site (the lambda is specialized per call, like a local generic function):


let inc \x: x + 1
inc(41)                      ; 42
    

Capturing Variables

Lambdas can capture variables from their enclosing scope. Captures are by value: the lambda receives a copy at the call, so later changes to the original are not seen by the closure, and an assignment to a captured name inside the lambda changes only the closure's copy — the original stays untouched:


let multiplier 10
let scale \x: x * multiplier    ; captures multiplier by value

scale(5)    ; returns 50
      

Function Types: Delegates

To pass a lambda as a value — to a parameter, into a field, or as a return value — the function type must be named. A delegate definition supplies that name:


define IntFn delegate(int) returns int

function apply(f: IntFn, x: int) returns int
    f(x)

let g: IntFn \x: x + 1
apply(g, 5)                  ; 6
      

A delegate-typed value is a closure: a record holding the captures plus the code to run. Calling through it is an indirect call.

A named function becomes such a value where a delegate type is declared, chosen among its overloads by the delegate's signature; so does a method bound to its receiver, written without its tuple:


function twice(n: int) returns int
    n * 2

apply(twice, 21)             ; 42
let c Counter(40)
apply(c.plus, 2)             ; c.plus is the method bound to c
      

Bound to a name without a delegate type, a function stays a compile-time value: let g twice costs nothing, and g(21) is a direct call.

Closure Lifetime

A closure bound to a local variable lives on the stack frame and dies with it. A closure returned from a function must outlive the frame, so the compiler allocates it on the caller's region automatically — factory functions work:


define IntFn delegate(int) returns int

function make_adder(n: int) returns IntFn
{
    return \x: x + n         ; allocated on the caller's region
}

let add5 make_adder(5)
let r add5(2)                ; 7
      

What is not allowed is smuggling a stack-allocated closure out of its frame — returning a local binding, storing it into an object that outlives the frame, or passing it to a callee that stores it. The compiler rejects these with a hard error:


function bad(n: int) returns IntFn
{
    let g: IntFn \x: x + n
    return g
    ; error: a closure bound to a local lives on the stack frame and
    ; cannot be returned - return the lambda directly (return \x: ...)
    ; so it is allocated on the caller's region
}
      

Implementation

A named-called lambda costs nothing at runtime: each call site is monomorphized into a direct call, with captures passed as leading arguments. A delegate-typed closure is a small record — a code pointer plus the captured values — and calling it is one indirect call. No garbage collector is involved either way.

Error Handling

Scaly uses explicit error handling. There are no exceptions or stack unwinding — errors are values returned from functions or procedures.

The throws Clause

Functions or procedures that can fail declare their error type with throws:


function parse(input: String) returns AST throws ParseError
{
    if invalid(input)
        throw ParseError.InvalidSyntax(String("expected expression"))
    ...
}
      

Under the hood, this is equivalent to returning Result[AST, ParseError], but with dedicated syntax for clarity.

Single Error Type

A function can only throw one error type. To represent multiple error kinds, use a union:


define FileError union
(
    NotFound: String
    PermissionDenied: String
    IoError: String
)

function read_file(path: String) returns String throws FileError
{
    ...
}
      

This ensures a clear error signature and enables the try/when pattern for handling specific variants.

The try/when Pattern

Handle errors with try and when clauses. On success, the binding holds the result and the when arms are skipped; on failure, the matching arm runs with the error value bound:


try let v read_thing(false)
    when e: NotFound
        print "not found"
    else
        print "other error"
      

If not all error variants are covered by when arms, an else clause is required.

Error Propagation

Use else throw to re-throw errors to the caller:


function process(x: int) returns int throws E
{
    try let v inner(x)
        else throw    ; re-throws E to the caller

    v * 2
}
      

When the error type matches exactly, the error propagates automatically. This simplifies deeply nested code like parsers and visitors.

Error Lifetime

Thrown values live in an exception region the caller provides. The compiler infers this from throw position; the explicit thrown-lifetime suffix ! may also be written on the thrown construction:


throw ParseError.InvalidSyntax(msg)!   ; explicit thrown lifetime

Union Types

A union type (also called sum type or tagged union) can hold one of several variants. Each variant optionally carries a payload of a single type (use a struct for several fields):


define Rectangle ( width: f64, height: f64 )

define Shape union
(
    Circle: f64
    Box: Rectangle
    Empty
)

let s Shape.Circle(5.0)
    

Memory Layout

A union is stored as a tag plus the largest variant:


Shape = { tag: u8, data: [size of largest variant] }
      

Unions can contain other unions. Nested unions contribute their full size (tag + data) when computing the parent union's size.

Pattern Matching with choose

Use choose-when to match on variants. Each when clause names a binding, then the variant, then the result; the binding is bound to the variant's payload:


choose s
    when r: Circle: r * r * 3.14
    when b: Box: b.width * b.height
    else 0.0

If not all variants are covered, an else clause is required.

Option Type

Option[T] is the standard library's union for nullable values:


define Option[T] union
(
    Some: T
    None
)

let o Option[int].Some(7)
choose o
    when v: Some: process(v)
    else handle_not_found()
      

Option Optimization

For non-pointer types, Option[T] optimizes to a pointer:

  • None = null pointer

  • Some(value) = pointer to value

Note that if T is itself a pointer, the Option Optimization leads to a pointer to another pointer which could be null itself.

Generics

Scaly supports generic types with type parameters in square brackets:


define Pair[T] ( first: T, second: T )
{
    function sum(this) returns T
        first + second
}

let p Pair[int](20, 22)
p.sum()                      ; 42
    

The standard library's containers are generic the same way: Vector[T], List[T], HashMap[K, V], Option[T], Result[T, E].

Monomorphization

Generics are implemented via monomorphization: each concrete instantiation becomes a completely separate type at compile time. Pair[int] and Pair[String] share no code at runtime — each has its own specialized implementation.

Benefits:

  • No runtime overhead — no type descriptors or vtables

  • Full optimization — the compiler sees concrete types

  • No boxing — primitives stay primitives

Trade-offs:

  • Larger binaries — each instantiation duplicates code

  • Longer compile times — more code to generate

Name Mangling

The Planner generates unique mangled names for each instantiation, following Itanium ABI conventions:


List[int]            → _ZN4ListIiE...
List[String]         → _ZN4ListI6StringE...
HashMap[String, int] → _ZN7HashMapI6StringiE...
      

These names are compatible with c++filt for debugging.

Lifetimes and Memory Regions

Scaly uses Region-Based Memory Management (RBMM). Instead of garbage collection or manual malloc/free, values that need the heap are allocated in memory regions (pages) that are deallocated in bulk when their owning scope exits.

Allocation Is Inferred

Ordinary code carries no memory-management annotations at all. A constructed value lives on the stack when that suffices; when its internals allocate, or the value must outlive the statement that created it, the compiler places the allocation in an appropriate region:

  • A value used only within the current function gets a local region, freed when the scope exits.

  • A value returned from a function is placed in the caller's region, so it survives the return.

  • A thrown error value is placed in the exception region the caller provides.

The standard library and the Scaly compiler itself are written in exactly this annotation-free style.

Escape Checking

The compiler proves that no reference outlives the memory it points into. A reference to a local that would escape the frame — through a return, through a store into an object that outlives the frame, or by being passed to a callee that stores it — is a hard compile-time error:


function dangling() returns pointer[int]
{
    var local 5
    return &local
}
; error: reference into a local page escapes via return - allocate the
; target with '#' (caller page) or '^name' (region) so it outlives the use
      

The check is interprocedural (it follows the value through calls into callees that store their parameters, and through closures) and conservative: code it accepts is safe.

Explicit Lifetime Suffixes

When a value must be placed in a region the inference would not choose — most commonly a heap object returned by pointer — an explicit lifetime suffix on the constructor call says where it lives. The suffix goes between the type and the argument list:

Caller (#)

The value is allocated in the caller's region. It survives the function return and is freed with the caller's scope. Used for returning heap-allocated values by pointer.

Reference (^name)

The value is allocated in the same region as the named value — for tying an allocation's lifetime to a specific longer-lived object, such as a collection it is stored into.

Thrown (!)

The value is allocated in the exception region. The compiler infers this for values in throw position, so writing it is rarely necessary.


define Node ( data: int )

function create_node(data: int) returns pointer[Node]
    Node#(data)              ; allocated in the caller's region

let n create_node(7)
(*n).data                    ; 7 — valid in the caller's scope
      

By-Value Returns

Functions can return values by value — the simplest and most common case, and the reason most code needs no lifetime suffix at all:


function create_point(x: int, y: int) returns Point
{
    Point(x, y)    ; constructed and returned by value
}
      

By-value return avoids region allocation entirely. The value is constructed directly in the caller's stack frame or register.