Chapter 18 Variadic Functions and Reflection
SourceTwo separate-but-related features that share this chapter because they lean on the same trick — the compiler statically knowing a count or a shape that most languages only figure out at runtime.
Variadic functions let a word take a statically-known number of arguments without hard-coding it. Reflection lets you write code that depends on a type's shape — field names, variant counts — with the inspection happening at compile time and no runtime representation needed. The first is about how many values; the second is about what values. Both are checked before your program runs.
Variadic input
A word's arity — how many values it consumes — is part of its name, written as a /N suffix. Declaring a variadic word uses a range where the N would normally be:
:fn sum/1 .. ( .. AnyInt -> AnyInt )
pop-> len
len 1 - [ drop + ] times
:end
sum/1.. means "sum takes at least one argument." Callers specify the arity at the call site with a concrete count:
3 5 sum/2 # ⌊8⌉
1 2 3 4 sum/4 # ⌊10⌉
The compiler checks the count is within the declared range. The ..AnyInt on the input side of the signature is the type-level way of saying "N values of type AnyInt", matched to the arity the caller asked for.
The range forms that appear in a declaration suffix:
| Suffix | Meaning |
|---|---|
/.. |
zero or more |
/1.. |
one or more |
/2..10 |
between 2 and 10 (inclusive) |
A single-point range /N..N is legal but an anti-pattern — a word that always takes a fixed number of values should just be an ordinary :fn with the inputs listed explicitly in the stack effect:
:fn triple ( AnyInt AnyInt AnyInt -> AnyInt )
+ +
:end
Reach for variadic syntax only when the arity genuinely varies at the call site.
Calling a variadic word
Call sites pick a concrete arity with a bare /N:
3 5 sum/2 # ⌊8⌉ — 2 arguments
1 2 3 4 sum/4 # ⌊10⌉ — 4 arguments
The compiler checks N falls within the range declared at definition time.
Variadic output
A word can also produce a statically-known number of outputs, by putting ..Type on the output side. The callee pushes the values, then pushes the count last:
:fn unpack/1.. ( ( Vec a | n ) -> .. a )
.. . # push elements, then push the count
:end
Callers use /N to specify how many outputs to expect:
my-vec unpack/3 pop-> c pop-> b pop-> a
This is the mechanism behind .collect. A quotation pushes some number of values; the compiler counts them statically; .collect reads the count and builds a Vec of exactly that length:
[ 1 2 3 ] .collect # ⌊[1 2 3]⌉ : (Vec Int | 3)
[ "a" "b" ] .collect # ⌊["a" "b"]⌉ : (Vec Str | 2)
Output variadics are what make .collect able to return a length-indexed Vec — the length isn't "whatever the runtime produces", it's a compile-time constant.
Variadic type parameters
The /N.. suffix works on record declarations too, producing types parameterised by a variable number of other types:
:record Tensor / 1 .. elem ( .. AnyUInt )
.data Bytes
:end
( Tensor F64 3 4 5 ) # 3D tensor, dims 3×4×5
( Tensor F64 28 28 ) # 2D tensor, dims 28×28
Tensor/1.. takes one ordinary type parameter (elem) and one or more AnyUInts (the dimensions). Each concrete application fills in a specific rank.
This is how anything rank-polymorphic — matrices, tensors, heterogeneous tuples — gets expressed without baking in a fixed rank.
Compile-time reflection
:type-info(T) is a compile-time expression that produces a TypeInfo value describing the shape of T. The TypeInfo is erased at runtime — the compiler inlines the answer — so there is no runtime cost. You get full shape information with zero footprint.
:fn type-name ( a -> Str )
drop :type-info ( a ) :match
| RecordInfo info =>
info .name
| UnionInfo info =>
info .name
| IntInfo _ =>
"Int"
| _ =>
"unknown"
:end
:end
42 type-name # ⌊"Int"⌉
2.0 1.0 Point type-name # ⌊"Point"⌉
TypeInfo is a union — RecordInfo, UnionInfo, EnumInfo, IntInfo, FloatInfo, FnInfo, and so on — each variant carrying a record that describes its shape. For a record, that includes the name, the fields (with names and types), and any derives on it.
You use reflection by pattern-matching on the :type-info result, same as any other union:
:fn field-count ( a -> Int )
drop :type-info ( a ) :match
| RecordInfo info =>
info .fields .len
| _ =>
0
:end
:end
:type-info on a value's type (the a above, consumed by drop) is the common case. :type-info(SomeConcreteType) reflects on a specific named type even when you don't have an instance.
Reflection-driven serialisation
Reflection is the substrate for things that would otherwise need a Serialize / Deserialize trait hierarchy. Silo's standard serialisation libraries (JSON, CBOR, Styx, etc.) walk :type-info(T) at compile time, produce a specialised serialiser for each concrete type, and monomorphise it down to the structural shape you'd have written by hand.
The practical consequence: there is no :derive Serialize on your record types. You just call the serialiser; it reflects on the shape; the compiler inlines the code. Add a field to a record and the serialiser picks it up automatically next compile, with no trait impls to update.
The same substrate drives the :derive lists you've already seen — [Eq Hash Debug] on a record declaration walks the record's :type-info at compile time and generates the field-by-field implementations. Everything derivable is derived by reflection.
When you actually use variadics
Most code doesn't declare variadic :fns. The common pattern is to call variadic words (vec/3, sum/4) without defining your own, and to use output variadics implicitly via .collect.
When you do define a variadic, it's usually because the alternative — a list of positional arguments wrapped in a record — would be noisier than the caller's /N annotation. Reach for variadics when the arity is a natural parameter at the call site.
Key points
:fn name/N..declares a variadic word. Range forms cover/..(zero+),/1..(one+),/M..N(bounded),/N(exact)...Typeon either side of the signature is a variadic slot; the caller's/Nsuffix fixes the count.
how(named block placeholder leaked into renderer — pair_named_blocks not run).collectreturns length-indexedVecs — the count is compile-time.- Records can be variadic too (
:record Tensor/1..), giving you rank-polymorphic types without a fixed rank. :type-info(T)is a compile-time reflection primitive producing aTypeInfounion. Zero runtime cost.- Serialisation,
:derivelists, and any structural-code-gen in the standard library are built on:type-info, not on aSerialize-like trait hierarchy.
Next: macros — how to extend the compiler with your own syntactic transformations.