GuideThe Silo HandbookData Types

Chapter 4 Data Types

Source

Silo 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.
  • :alias makes names interchangeable; :type makes them distinct, at zero runtime cost.
  • Every declaration can carry a derive list [Trait1 Trait2 …] for auto-implementations; * and - tokens control inheritance on newtypes.
  • :pred declares 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.