20. Templates
20.1 Template Functions
A function becomes a template when any of its parameters have an unspecified type. The compiler introduces a type parameter for each untyped argument and monomorphizes the function at each call site. You can also declare type parameters explicitly with angle brackets.
-- Leaving a parameter's type unspecified makes it a template.
-- The compiler infers a type parameter for each untyped argument.
fn identity(x) = x;
-- Equivalent explicit template syntax:
fn identity<T>(x T) T = x;
-- Multiple untyped parameters: each gets its own type parameter
fn pick_first(a, b) = a; -- equivalent to pick_first<A, B>(a A, b B) A
fn pick_second(a, b) = b; -- equivalent to pick_second<A, B>(a A, b B) B
-- Explicit type parameters
fn pick_first<A, B>(a A, b B) A = a;
fn pick_second<A, B>(a A, b B) B = b;
-- Mixing typed and untyped parameters: only untyped ones become template parameters
fn repeat(x, n Int) = { -- x is generic, n is Int
let result = [];
for (i in 0..n) { result = result ++ [x]; }
result
};
-- Type inferred from arguments
42 identity println; -- T inferred as Int
"hello" identity println; -- T inferred as String
-- Explicit type parameter at call site
42 identity<Int> println;
-- Templates work with arrays and tuples
fn wrap(x) = [x];
fn pair(a, b) = (a, b);
5 wrap println; -- [5]
println(pair(1, "hi")); -- (1, "hi")
20.2 Template Structs
struct Pair<T, U> {
first T,
second U
}
-- Type arguments inferred from field values
let p = Pair { first: 42, second: "hello" };
p.first println; -- 42
p.second println; -- "hello"
-- Explicit type arguments
let q = Pair<Int, Float> { first: 10, second: 2.5 };
-- Nested template types
struct Box<T> { value T }
let nested = Box { value: Pair { first: 1, second: 2 } };
nested.value.first println; -- 1
-- Template tuple structs
struct Wrapper<T>(T);
let w = Wrapper(42); -- T inferred as Int
w println; -- Wrapper<Int>(42)
w.0 println; -- 42
let ws = Wrapper("hello"); -- T inferred as String
ws println; -- Wrapper<String>(hello)
20.3 Template Enums
-- Option<T> is a built-in template enum (see below)
-- User-defined template enums
enum Either<L, R> {
left L,
right R,
}
-- Type inferred from data case
let x = Either.left(42); -- Either<Int, ?>
-- Explicit type for no-data or ambiguous cases
let y = Either<Int, String>.left(42);
-- Pattern matching on template enums
fn describe<L, R>(e Either<L, R>) String {
match (e) {
Either.left(v): return "left";
Either.right(v): return "right";
}
""
}
Built-in Option<T>
Option<T> is a built-in template enum with cases some T and none. It is used for type-safe handling of missing values — for example, map subscript and get(map, key) return Option<V>.
-- Construct Option values
let x = Option.some(42); -- Option<Int>
let y = Option<Int>.none; -- explicit type for none
-- Built-in utility functions
x unwrap println; -- 42
y unwrapOr(0) println; -- 0
x isSome println; -- true
y isNone println; -- true
-- Pattern matching works as with any enum
match (x) {
Option.some(v): v println;
Option.none: "nothing" println;
}
20.4 Template Constraints
Template constraints restrict what types can be used for a type parameter. When a concrete type does not satisfy the constraint, the template is silently skipped during overload resolution, allowing other overloads to match or producing a clear "no matching overload" error.
Constraint Declarations
There are three forms of constraint declaration.
Type-set constraints list the concrete types that are allowed:
-- Only Int, Float, and Fraction may be used where Numeric is required
constraint Numeric = Int | Float | Fraction;
Constraint names can be used inside type expressions when defining other constraints. The resulting constraint matches any combination of the referenced constraint's allowed types:
constraint Rational = Int | Fraction;
-- Tuple containing any combination of Rational types
constraint RationalPair = (Rational, Rational);
-- matches (Int, Int), (Int, Fraction), (Fraction, Int), (Fraction, Fraction)
-- Array of Rational values
constraint RationalArray = [Rational];
-- matches [Int] and [Fraction]
-- Template struct with constraint type arguments
struct Pair<A, B> { first A; second B; }
constraint RationalPairStruct = Pair<Rational, Rational>;
-- matches Pair<Int, Int>, Pair<Int, Fraction>, etc.
-- Function type with constraint parameters and return type
constraint RationalUnaryFn = (Rational) Rational;
-- matches (Int) Int, (Int) Fraction, (Fraction) Int, etc.
This works with any type expression: maps, sets, refs, and any nesting depth.
For example, [Pair<Rational, [Rational]>] would match
[Pair<Int, [Fraction]>], [Pair<Fraction, [Int]>], etc.
Note: Constraints are not supertypes. A container of a constraint
type like [Rational] does not create a heterogeneous container
that mixes Int and Fraction values together. It matches
a homogeneous [Int] or a homogeneous [Fraction], because
all types are resolved statically at compile time. The same applies to
maps, sets, tuples, and any other parameterized type.
Structural constraints require that certain functions or operators exist for the type:
-- Any type T that has a < and > operator returning Bool
constraint Comparable<T> = requires {
<(T, T) Bool,
>(T, T) Bool
};
-- A type T that has an addition operator
constraint Addable<T> = requires {
+(T, T) T
};
Composition constraints combine multiple constraints with &:
-- Ordered requires both Numeric and Comparable
constraint Ordered<T> = Numeric & Comparable<T>;
Applying Constraints
Constraints are applied to type parameters using either inline syntax
or a where clause.
Inline syntax — place the constraint after the type parameter with a colon:
-- T must satisfy Numeric
fn add<T: Numeric>(a T, b T) T = a + b;
-- Multiple constraints with &
fn clamp<T: Comparable & Numeric>(val T, lo T, hi T) T {
if (val < lo) lo
else if (val > hi) hi
else val
}
Where clause — place constraints after the return type:
fn multiply<T>(a T, b T) T where T: Numeric = a * b;
-- Multiple constrained type parameters
fn merge<K, V>(a [K: V], b [K: V]) [K: V]
where K: Comparable {
-- ...
}
Constraint as parameter type — use a constraint name directly as a parameter type. Each occurrence generates its own type parameter, so different arguments may have different concrete types:
-- Using a constraint name as a parameter type:
fn show(x Numeric) String = toString(x);
-- The above is sugar for:
-- fn show<__T0: Numeric>(x __T0) String = toString(x);
-- Each parameter gets its own type parameter, so mixed types are allowed:
fn show_both(a Numeric, b Numeric) String = toString(a) $ " " $ toString(b);
show_both(1, 2) println; -- "1 2"
show_both(1, 2.5) println; -- "1 2.5" (Int and Float are both Numeric)
-- To require both arguments to be the same type, use explicit generics:
fn add<T: Numeric>(a T, b T) T = a + b;
-- Constraint sugar can be mixed with explicit type parameters:
fn mixed<T: Numeric>(a T, b Addable) String = toString(a);
-- And with concrete types:
fn label(x Numeric, s String) String = s;
Constraint as return type — a constraint name in the return type position means the return type is inferred from the body and then validated against the constraint. Each parameter and the return type are independently constrained, so they may all be different concrete types:
constraint AsSignal = Int | Float;
-- Return type is inferred from the body, then checked against AsSignal
fn mix(a AsSignal, b AsSignal) AsSignal {
a + b
}
mix(1, 2.5) println; -- 3.5 (a: Int, b: Float, returns Float)
mix(3, 4) println; -- 7 (a: Int, b: Int, returns Int)
-- Tuple return types work too
fn stereo(a AsSignal, b AsSignal) (AsSignal, AsSignal) {
(a + b, a - b)
}
stereo(1, 2.5) println; -- (3.5, -1.5)
-- The compiler rejects return types that don't satisfy the constraint:
-- fn bad(x AsSignal) AsSignal { toString(x) } -- error: String does not satisfy AsSignal
Constraints on Structs, Enums, and Type Aliases
Constraints can be placed on any templated declaration, not just functions.
-- Struct with constrained type parameter
struct SortedArray<T: Comparable> { items [T] }
-- Enum with constrained type parameter
enum NumResult<T: Numeric> { ok T, err String, }
-- Type alias with constraint
type NumPair<T: Numeric> = (T, T);
Constraint-Based Overload Filtering
When a constrained template does not match, the compiler moves on to the next candidate. This lets you write specialized overloads alongside constrained templates:
constraint Numeric = Int | Float;
fn describe<T: Numeric>(x T) String = "numeric";
fn describe(x String) String = "string";
describe(42) println; -- "numeric"
describe("hello") println; -- "string" (template skipped because String is not Numeric)
Complete Example
constraint Numeric = Int | Float;
constraint Addable<T> = requires {
+(T, T) T
};
constraint NumericAddable<T> = Numeric & Addable<T>;
fn triple<T: NumericAddable>(x T) T = x + x + x;
triple(5) println; -- 15
triple(1.0) println; -- 3.0
-- triple("hi"); -- error: String does not satisfy NumericAddable
20.5 Template Lambdas
Lambda expressions can have type parameters, creating generic lambdas that are monomorphized at each call site. Just like template functions, leaving a parameter's type unspecified makes the lambda generic. You can also declare type parameters explicitly with angle brackets.
-- Omitting parameter types makes a template lambda (sugar for fn<T>(x T))
let double = fn(x) { x + x };
double(3) println; -- 6 (T = Int)
double(3.14) println; -- 6.28 (T = Float)
-- Equivalent explicit template syntax:
let double2 = fn<T>(x T) T { x + x };
Captures
Template lambdas capture variables from their enclosing scope, just like regular lambdas. The captured values are shared across all monomorphizations.
let offset = 10;
let shifted = fn(x) { x + offset };
shifted(5) println; -- 15 (T = Int)
shifted(2.5) println; -- 12.5 (T = Float, offset promoted to Float)
Higher-Order Functions
Template lambdas can be passed to higher-order functions. The compiler specializes the lambda based on the expected function type.
let add = fn(a, b) { a + b };
-- Pass to a function expecting fn(Int, Int) Int
let nums = [1, 2, 3, 4, 5];
fold1(nums, add) println; -- 15
Constrained Template Lambdas
Constraints can be applied to template lambda type parameters using the same inline colon syntax as template functions.
constraint Numeric = Int | Float;
let square = fn<T: Numeric>(x T) T { x * x };
square(4) println; -- 16
square(2.5) println; -- 6.25
-- square("hi"); -- error: String does not satisfy Numeric
Return Type Inference
The return type can be omitted and will be inferred from the body, just like regular lambdas.
let triple = fn(x) { x + x + x };
triple(7) println; -- 21
triple(1.5) println; -- 4.5
Pipeline Syntax
Template lambdas stored in variables work with the space-pipeline syntax.
let double = fn(x) { x + x };
42 double println; -- 84
3.14 double println; -- 6.28
Multiple Type Parameters
-- Multiple untyped params get separate type parameters
let pair = fn(a, b) { (a, b) };
pair(1, "hello") println; -- (1, "hello")
pair(3.14, true) println; -- (3.14, true)
20.6 Existential Types (some C)
A template parameter <T: C> is resolved to one concrete type at each
call site, so a function written that way is specialized per type and a
[Drawable]-style container would have to be homogeneous (see the
note in Template Constraints — constraints
are not supertypes). An existential type, written some C,
takes the opposite view: it is a value of some hidden type that
satisfies the structural constraint C, with the concrete type
erased. Different some C values may wrap different types, yet all
can be used through C's methods.
A concrete value is wrapped in some C automatically wherever it
flows into a some C slot — a function argument, a binding, or a
collection element. (In the type-theory literature this operation is called
pack.) Calling one of C's required functions on a
some C value dispatches to the wrapped type's implementation:
constraint Drawable<T> = requires { draw(T) String };
struct Circle { r Float; }
struct Square { side Int; }
fn draw(c Circle) String = "circle";
fn draw(s Square) String = "square";
-- One function, many hidden types. The argument is wrapped; draw(x) dispatches.
fn render(x some Drawable) String = draw(x);
render(Circle { r: 2.0 }) println; -- circle
render(Square { side: 3 }) println; -- square
-- A binding works the same way; the concrete type is hidden afterward.
let d some Drawable = Circle { r: 1.0 };
draw(d) println; -- circle
some is a contextual keyword: it is special only at the start
of a type, and remains an ordinary identifier elsewhere (for instance as the
some case of Option).
Object Safety
Only constraints whose methods can dispatch on a single hidden value may be
used existentially. Each required function may mention the type variable in
at most one parameter (the value being dispatched on); every other
parameter must be a concrete type. A constraint with a binary method
such as +(T, T) T is therefore rejected: two some C values
could wrap different hidden types, so there is no single concrete
+ to call.
constraint Addable<T> = requires { +(T, T) T };
-- Compile error: + uses T in two argument positions, so `some Addable`
-- cannot be formed (the binary-method problem).
-- fn bad(x some Addable) ...
Constraints like Drawable above — where the type variable appears
once, as the value's own type — are object-safe. (Constraints are still
free to use binary methods; the restriction applies only when the constraint
is used as some C.)
Composition
A composition constraint A & B is existential-safe when both
components are, and a some (A & B) value can dispatch the methods of
either:
constraint Named<T> = requires { name(T) String };
constraint Sized<T> = requires { size(T) Int };
constraint NamedSized<T> = Named<T> & Sized<T>;
fn describe(x some NamedSized) String = name(x) $ "/" $ toString(size(x));
describe(Square { side: 9 }) println; -- square/4
Constraints exported from a module work as some C in importing modules
just like locally declared ones.
Heterogeneous Collections
This is where existentials differ most from plain constraints: an array, list,
or persistent vector of some C may hold values of different concrete
types together. Each element is wrapped as it is added:
let shapes [some Drawable] = [Circle { r: 1.0 }, Square { side: 3 }];
draw(shapes[0]) println; -- circle
draw(shapes[1]) println; -- square
let lst List<some Drawable> = List(Square { side: 2 }, Circle { r: 5.0 });
draw(lst head) println; -- square
Auto-mapping a constraint method over such a collection dispatches per element, through each value's own implementation:
shapes draw println; -- [circle, square]
lst draw println; -- List(square, circle)
Existentials vs. Any: both erase a value's
concrete type, but they answer different needs. Any accepts any
value and is consumed by testing its type at runtime with match /
as(Type). some C accepts only values satisfying a known
constraint and is consumed by calling that constraint's methods, which
dispatch to the right implementation without any runtime type test — useful
when you want behavior behind an interface rather than open-ended inspection.
some C relates to existential and interface
features in other languages — Haskell's existential quantification,
Rust's dyn Trait, Swift's any P, and Go interfaces,
including the witness-dictionary representation and the object-safety rule
— see Existential Types:
Tzopilotl vs. Haskell, Rust, Swift, and Go.