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,f64JavaScript:
number,string,booleanWebAssembly:
i32,i64,f32,f64JVM:
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.
; 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
; 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 lifetimeUnion 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.0If 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 pointerSome(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.