Chapter 4 Data Types
SourceSilo has five ways to declare a named data type. Each serves a different job; the five are complementary rather than overlapping. If you've used Rust, the shapes will be familiar:
| Silo | Rust analogue | Role |
|---|---|---|
:record |
struct with named fields |
Product type: one of each |
:union |
enum with data variants |
Sum type: one of several |
:enum |
C-style enum |
Integer-backed finite set |
:alias |
type alias |
Transparent synonym |
:type |
struct Wrapper(Inner) |
Distinct newtype |
On top of those five keywords Silo adds two more:
| Silo | Role |
|---|---|
[…] after the name |
Derive list for auto-impls |
:pred |
Compile-time predicate over a type's shape |
This chapter goes through each in turn.
Records
A :record is a product type — a bundle of named fields. Fields are listed one per line, each with .name followed by its type.
:record Point
.x F64
.y F64
:end
You construct a record by pushing the fields in reverse declaration order, then the type name. The constructor pops top-of-stack first and assigns it to the first-declared field:
2.0 1.0 Point # ⌊Point{ x ↦ 1.0 y ↦ 2.0 }⌉
# pops top (1.0) → .x (first declared), pops next (2.0) → .y
Read the source right-to-left and you get the same visual order as the field declarations: "1.0 (for x), 2.0 (for y)".
Access a field with .name, and do a functional update (returning a new record, not mutating) with .name<-:
2.0 1.0 Point pop-> p
p .x # ⌊1.0⌉
p 3.0 .x<- # ⌊Point{ x ↦ 3.0 y ↦ 2.0 }⌉
.x<- takes the old record and a new value for x and produces the updated record. The field selector and the field updater are just ordinary words — nothing special about the dot syntax.
Unions
A :union is a sum type: a value that is one of several named variants. Each variant lists its payload's types, with optional field names for destructuring convenience.
:union Shape
| Circle F64
| Rect .width F64 .height F64
:end
You construct a variant by pushing its payload in reverse declaration order (same convention as records), then the variant name:
5.0 Circle # ⌊Circle(5.0)⌉
4.0 3.0 Rect # ⌊Rect(3.0 4.0)⌉
# pops 3.0 → .width, pops 4.0 → .height
# display shows payload in pop order: width first, then height
You take a union apart with :match. Inside a match arm, the variant's fields bind to names:
:fn area ( Shape -> F64 )
:match
| Circle r =>
r r * 3.14159 *
| Rect .width w .height h =>
w h *
:end
:end
Circle r binds the single payload value to r. Rect .width w .height h destructures by field name — the declared field names in the union make this work.
Enums
An :enum is a union where every variant is empty: a finite integer-backed set, like a C-style enum or a Rust unit-only enum. Variants are lowercase, unlike union variants:
:enum Direction
| north
| south
| east
| west
:end
You access a variant with (TypeName .variant):
( Direction .north) pop-> d
Enums exist as a separate keyword (and not just as a union of empty variants) because they carry extra guarantees: the integer backing is stable, they're cheap to hash, they derive Eq/Ord/Hash for free, and they're the right type for anything that genuinely is a small finite set of tags.
Aliases and newtypes
:alias makes a transparent synonym. The compiler treats both names as the same type everywhere:
:alias Meters F64
Now Meters and F64 are interchangeable. A function taking Meters accepts any F64, and vice versa. Aliases are useful for readability; they don't add any type-level distinction.
:type makes a distinct wrapping. Even if two newtypes have the same underlying representation, the compiler won't let you mix them:
:type UserId ( Int 0 .. )
:type PostId ( Int 0 .. )
A UserId is not a PostId, even though both are non-negative Ints at runtime. This is the type-level equivalent of a tagged wrapper, with zero runtime cost — the newtype is an artefact of the type system only.
Use an alias when the name is just for documentation; use a newtype when passing the wrong one should be a compile error.
Derive lists
Every type declaration can include a derive list in square brackets before the fields:
:record Color [ Eq Hash Debug]
.r U8
.g U8
.b U8
:end
Each name in the list is a trait the compiler will auto-implement for the type. Most traits that can be derived generate their implementation mechanically from the declared field or variant structure — Eq compares field-by-field, Hash hashes each field, Debug prints using the structural layout.
Two special tokens control inheritance on newtypes:
:type MyWidget Widget [ * -Display ]
* means "derive every trait the wrapped type derives"; -Display opts out of a specific one. This is useful when you want a newtype to inherit almost everything from the underlying type but override one particular trait.
Derives are covered in more detail in the traits chapter.
Predicates
A :pred declares a compile-time predicate over a type's shape. The predicate has no runtime representation — it's purely a restriction the compiler checks when you use the predicate as a constraint.
:pred Signed ( ( Int ( RangeBoth min max )) ) { ( min 0 <) }
:pred Unsigned ( ( Int ( RangeBoth min max )) ) { ( min 0 >=) }
:pred NonZero ( ( Int ( RangeBoth min max )) ) { ( min 0 >) }
:pred ByteSized ( ( Int ( RangeBoth min max )) ) { (( max min -) 2 ^8 <) }
Read :pred Signed ( (Int (RangeBoth min max)) ) { (min 0 <) } as "the predicate Signed matches any type of the form (Int (RangeBoth min max)), and holds when min < 0". The pattern destructures the type; the { … } is the compile-time expression that decides whether the predicate holds.
This is one of the things that makes Silo dependently typed without needing to write proofs. I8 is (Int -128..128), so (Signed I8) unifies min = -128, max = 128, and checks -128 < 0 — which is true. (Signed U8) would try min = 0, fail 0 < 0, and be rejected at compile time.
You use predicates as constraints on generic words, the same way you use trait bounds:
:fn negate ( val -> val ) { ( Signed val ) }
0 swap -
:end
42 :I8 negate # OK — I8 is Signed
# 42:U8 negate # compile error: (Signed U8) not satisfied
Predicates are for gatekeeping, not dispatch. Use traits when you need different code for different types; use predicates when you need to stop unsuitable types from ever reaching a given function. The full story on predicates, including the relationship to refinement types, is in the type system chapter.
Key points
- Records, unions, enums, aliases, and newtypes cover every standard shape of named data.
- Records use named-field destructuring, construction in declaration order, and
.name/.name<-for access and functional update. - Union variants are destructured in
:match. Declared field names in a variant let you match by name. - Enums are cheap integer-backed finite sets; lowercase variants distinguish them from unions.
:aliasmakes names interchangeable;:typemakes them distinct, at zero runtime cost.- Every declaration can carry a derive list
[Trait1 Trait2 …]for auto-implementations;*and-tokens control inheritance on newtypes. :preddeclares a compile-time constraint over a type's shape, usable as a bound on generic functions. Predicates cost nothing at runtime.
The next chapter goes deeper on the type system — generics, higher-kinded types, dependent types, and how it all fits together.