Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13: Generics and Type Constraints

Audience: Experienced programmers
Time: 120 minutes
Prerequisites: 02-Values-and-Types, 07-Classes-and-Instances, 08-Interfaces-and-Protocols
You'll learn: Generic types, type parameters, constraints, practical uses, common pitfalls


The Big Picture

So far, you've written code that works with specific types: a List(int), a HashMap(str, int). But what if you want a function that works with any type? That's where generics come in.

Generics let you write code that's type-safe but reusable across different types:

class Container(T)
    var item: T
    
    def get(): T
        return item

Container(int) holds integers. Container(str) holds strings. Same code, different types. This is safer than using object or any, because the type checker still verifies you don't mix types.


Generic Classes

Generics Instantiation

The simplest generic is a container that holds a single value:

# file: 13_generic_container.zbr
# teaches: generic class definition
# chapter: 13-Generics-and-Type-Constraints

class Container(T)
    var item: T
    
    def store(value: T)
        item = value
    
    def retrieve(): T
        return item

def main()
    var int_box = Container(int)()
    int_box.store(42)
    print(int_box.retrieve())  # Output: 42
    
    var str_box = Container(str)()
    str_box.store("hello")
    print(str_box.retrieve())  # Output: hello

Notice the syntax:

  • Container(T) — T is a type parameter
  • Container(int)() — substitute int for T, then create an instance
  • Inside the class, T can be used like any other type

You can have multiple type parameters:

# file: 13_generic_pair.zbr
# teaches: multiple type parameters
# chapter: 13-Generics-and-Type-Constraints

class Pair(K, V)
    var key: K
    var value: V
    
    def set_key(k: K)
        key = k
    
    def set_value(v: V)
        value = v
    
    def get_key(): K
        return key
    
    def get_value(): V
        return value

def main()
    var p = Pair(str, int)()
    p.set_key("count")
    p.set_value(42)
    print("Key: ${p.get_key()}, Value: ${p.get_value()}")

Generic Methods

You can also write generic methods within regular classes:

# file: 13_generic_methods.zbr
# teaches: generic methods
# chapter: 13-Generics-and-Type-Constraints

class Utils
    static
        def identity(T)(value: T): T
            return value

        def first_of_three(T)(a: T, b: T, c: T): T
            return a

def main()
    var x = Utils.identity(int)(42)
    print(x)  # Output: 42

    var y = Utils.identity(str)("hello")
    print(y)  # Output: hello

    var z = Utils.first_of_three(int)(1, 2, 3)
    print(z)  # Output: 1

A generic method declares its type parameters in their own parentheses before the value parameters -- def identity(T)(value: T): T -- and the caller passes the type first: Utils.identity(int)(42). Zebra does not infer the type argument from the values; Utils.identity(42) is refused with a message showing the explicit form. Instance methods work the same way (box.wrap(int)(7)).


Generic Functions

Top-level functions can be generic too. The type parameters come in their own parens before the value parens — def fname(T)(value: T): T:

# file: 13_generic_function.zbr
# teaches: top-level generic function declaration
# chapter: 13-Generics-and-Type-Constraints

def identity(T)(value: T): T
    return value

def first(T)(items: List(T)): T?
    if items.count() == 0
        return nil
    return items.at(0)

def main()
    var n: int = identity(int)(42)
    var s: str = identity(str)("hello")
    var first_num: int? = first(int)([1, 2, 3])
    if first_num as v
        print(v)  # 1
    print(s)      # hello

Rules:

  • The type parameter list (T) (or (T, U) etc.) comes immediately after the function name.
  • Inside the body, T is a regular type — usable in annotations, casts, and return types.
  • At the call site the type argument is required: identity(int)(42). A bare identity(42) is refused -- the type is not inferred from the arguments.
  • A type name that is declared nowhere (for example writing def total(items: List(T)) without the (T)) is refused as unknown type 'T', with the generic spelling as the hint.
  • Under the hood the compiler emits a comptime T: type parameter to Zig.

When to use:

  • Generic function — utility code that works for any type (identity, first, swap).
  • Generic method — operation that belongs to a generic class (covered above).
  • Generic class — container or wrapper around values of any type (Stack(T), Cache(K, V)).

The three forms cover almost every generic pattern you'll need.


Generic Collections

You already use generics implicitly with List and HashMap:

# file: 13_generic_collections.zbr
# teaches: using generic stdlib types
# chapter: 13-Generics-and-Type-Constraints

def main()
    var numbers: List(int) = List()
    numbers.add(1)
    numbers.add(2)
    numbers.add(3)
    
    for n in numbers
        print(n)
    
    var ages: HashMap(str, int) = HashMap()
    ages.put("Alice", 30)
    ages.put("Bob", 25)
    
    for name, age in ages
        print("${name}: ${age}")

These are all generic types. The standard library provides them pre-built.


Type Constraints

Sometimes you want a generic that works with any type that implements an interface:

# file: 13_type_constraints.zbr
# teaches: interface constraints
# chapter: 13-Generics-and-Type-Constraints

interface Printable
    def display(): str

class Dog implements Printable
    def display(): str
        return "Woof!"

class Cat implements Printable
    def display(): str
        return "Meow!"

class Printer
    static
        def print_item(item: Printable)
            print(item.display())

def main()
    var dog = Dog()
    var cat = Cat()
    
    Printer.print_item(dog)  # Output: Woof!
    Printer.print_item(cat)  # Output: Meow!

Here, Printer.print_item accepts any type that implements Printable. This is a constraint: "T must implement interface Printable".

More advanced: a constraint on a generic class's type parameter. The interface itself takes a type parameter — Comparable(T) — which is how a class names itself in the signature (Score implements Comparable(Score)), and where T implements Comparable(T) is checked when the class is instantiated: ComparableList(int)() is refused, because a primitive cannot implement an interface.

# file: 13_generic_constraints_advanced.zbr
# teaches: constraints in generic methods
# chapter: 13-Generics-and-Type-Constraints

interface Comparable(T)
    def compareTo(other: T): int

class Score implements Comparable(Score)
    var value: int
    cue init(v: int)
        value = v
    def compareTo(other: Score): int
        return value - other.value

class ComparableList(T where T implements Comparable(T))
    var items: List(T) = List(T)()

    def add(item: T)
        items.add(item)

    def find_max(): T?
        if items.count() == 0
            return nil
        var max = items.at(0)
        var i = 1
        while i < items.count()
            var item = items.at(i)
            # T implements Comparable(T), so compareTo is available
            if item.compareTo(max) > 0
                max = item
            i = i + 1
        return max

def main()
    var list = ComparableList(Score)()
    list.add(Score(10))
    list.add(Score(5))
    list.add(Score(20))
    if list.find_max() as max
        print("Max: ${max.value}")  # Max: 20

Comparable(T) is not cue compare. The interface above is a constraint you declare and you call (item.compareTo(max)). The language's own ordering protocol is the compare cue (Chapter 07, "Cues"): <, <=, >, >= and list.sort() call cue compare, and they do not look at compareTo. Score(1) < Score(2) does not build with only the interface. If you want both, give the class both; the cue's body can simply return .compareTo(other). Like every cue compare, it needs a cue equals on the same class.


Type Aliases and Refinement Types

A different flavour of "generic" — instead of parameterising over types, you can parameterise over values that constrain a base type at runtime.

Plain type aliases

A type declaration creates a named alias for an existing type, optionally with a where constraint:

# file: 13_type_aliases.zbr
# teaches: type aliases with constraints
# chapter: 13-Generics-and-Type-Constraints

type PositiveInt = int where value > 0
type NonEmptyStr = str where value.len > 0
type Ratio       = float where value >= 0.0 and value <= 1.0
type UncheckedInt = int                       # no constraint — plain alias

def main()
    var count: PositiveInt = 42               # OK — 42 > 0
    var doubled: int = count * 2              # transparent — works as int
    print(doubled)  # 84

    # var bad: PositiveInt = -1               # runtime panic: "type constraint 'PositiveInt' failed"

Key properties:

  • Transparent. PositiveInt is just int in the generated Zig — no wrapper struct, no runtime overhead beyond the constraint check.
  • value is the implicit binding in the where clause — refers to the variable being declared.
  • Check at initialization. The compiler emits a runtime check after every var x: AliasType = expr declaration. If the constraint fails, the program panics with the alias name in the message.
  • --turbo strips checks — the same flag that strips contract assertions (Chapter 14) also strips type-alias checks for production builds.

Refinement types — aliases with value parameters

A type alias can carry value parameters that are bound into the constraint. Same family of alias, many different bounds:

# file: 13_refinement_types.zbr
# teaches: parametric refinement types
# chapter: 13-Generics-and-Type-Constraints

type Bounded(lo: int, hi: int) = int where value >= lo and value <= hi

def main()
    var score: Bounded(0, 100) = 85           # OK
    var neg:   Bounded(-50, 50) = -20         # OK
    # var bad: Bounded(0, 100) = 150          # runtime panic

    var temp: Bounded(-273, 1000) = 37        # body temperature — OK
    print(temp)  # 37

The parameters (here lo and hi) sit alongside value in the where expression. Each var x: Bounded(...) declaration substitutes the parameter values and emits the check.

Struct base types

The base type can be a struct, and the constraint can inspect fields:

struct Range
    var lo: int
    var hi: int

type ValidRange = Range where value.lo < value.hi

def main()
    var r: ValidRange = Range(lo: 0, hi: 10)  # OK
    # var bad: ValidRange = Range(lo: 5, hi: 3)  # runtime panic

Struct aliases don't carry value parameters in v1.0 — the parametric form is for primitive base types only.

When to reach for refinement types

  • Domain invariants that should hold at every assignment — port numbers in 1..65535, percentages in 0..100, ages in 0..150.
  • Self-documenting parameter types — Bounded(0, 100) reads better than int plus a comment.
  • Bug-catching in tests — paired with --turbo-stripped release builds, you pay the check cost only during development.

For invariants on whole functions (not just variable assignments), prefer require / ensure contracts — see Chapter 14.


Real World: Generic Cache

A practical example: a cache that works with any type:

# file: 13_generic_cache.zbr
# teaches: realistic generic class
# chapter: 13-Generics-and-Type-Constraints

class Cache(K, V)
    var data: HashMap(K, V) = HashMap()
    var max_size: int
    
    cue init(max_size: int)
        this.max_size = max_size
    
    def put(key: K, value: V)
        if data.count() >= max_size and not data.contains(key)
            # Evict first key (simplistic LRU simulation)
            # In real code, track access order
            pass
        data.put(key, value)
    
    def lookup(key: K): V?
        if data.contains(key)
            return data.fetch(key)
        return nil
    
    def clear
        data = HashMap()

def main()
    var cache = Cache(str, int)(3)   # the ctor args go on the constructor call
    cache.put("a", 1)
    cache.put("b", 2)
    cache.put("c", 3)
    
    var val = cache.lookup("b")
    if val != nil
        print("Got: ${val}")

Common Mistakes

Mistake 1: Forgetting to Instantiate Generic Parameters

# WRONG
var box = Container()  # Error: T not specified
box.store(42)

# CORRECT
var box = Container(int)()
box.store(42)

Mistake 2: Mixing Types in a Generic Container

# WRONG
var box = Container(int)()
box.store("hello")  # Error: Expected int, got str

# CORRECT
var box = Container(str)()
box.store("hello")

Mistake 3: Forgetting to Declare the Type Parameter

# WRONG - T is used but never declared
def find_max(items: List(T)): T     # error: unknown type 'T'
    var max = items.at(0)
    for item in items
        if item > max
            max = item
    return max

# CORRECT - declare T, and pass it at the call
def find_max(T)(items: List(T)): T
    var max = items.at(0)
    for item in items
        if item > max
            max = item
    return max

# find_max(int)([3, 9, 4]) is 9

The body is checked for each type it is called with: find_max(int) and find_max(str) compile; a type with no > does not (today the error comes from Zig, not Zebra).

Mistake 4: Expecting Runtime Type Tests on T

Type parameters are not erased. Each distinct type argument compiles its own copy of the function (Zig's comptime monomorphization), so inside process(int) the parameter simply is int, and there is nothing to test at runtime. Code that only makes sense for some types belongs behind an interface constraint (T where T implements Comparable(T)) or in a separate, non-generic function -- not behind a runtime check on T.


Exercises

Exercise 1: Generic Stack

Implement a Stack(T) class with push, pop, and is_empty methods:

Solution
class Stack(T)
    var items: List(T) = List()
    
    def push(value: T)
        items.add(value)
    
    def pop(): T?
        if items.count() == 0
            return nil
        var value = items.at(items.count() - 1)
        # Remove last item (simplified - no remove method)
        return value
    
    def is_empty(): bool
        return items.count() == 0

def main()
    var stack = Stack(int)()
    stack.push(1)
    stack.push(2)
    stack.push(3)
    
    var val = stack.pop()
    if val != nil
        print("Popped: ${val}")

Exercise 2: Filter Function with a sig Predicate

Write a function that filters a list of int based on a predicate. Function parameters that accept lambdas must use a named sig type — inline def(T): R types are not valid in parameter position (see Chapter 04).

Solution
sig IntPredicate(x: int): bool      # name the function type

class ListUtils
    static
        def filter(items: List(int), pred: IntPredicate): List(int)
            var result: List(int) = List()
            for item in items
                if pred(item)
                    result.add(item)
            return result

def is_even(x: int): bool
    return x % 2 == 0

def main()
    var numbers: List(int) = List()
    numbers.add(1)
    numbers.add(2)
    numbers.add(3)
    numbers.add(4)

    var evens = ListUtils.filter(numbers, is_even)

    for e in evens
        print(e)

A truly generic filter — filter(T)(items: List(T), pred: ???) — would need a generic sig type. Generic sigs aren't part of the language yet, so the idiomatic move is to write one concrete filter per element type, or to write a generic function whose predicate is a comptime callable (see the generic functions section of QUICKSTART for advanced patterns).

Exercise 3: Generic Wrapper with Validation

Create a ValidatedBox(T) that only accepts values passing a validation function:

Solution
class ValidatedBox(T)
    var item: T?
    var validator: T -> bool
    
    cue init(validator: T -> bool)
        this.validator = validator
    
    def set(value: T): bool
        if validator(value)
            item = value
            return true
        return false
    
    def get(): T?
        return item

def main()
    var age_box = ValidatedBox(int)()
    age_box.init({ x in x >= 0 and x <= 150 })
    
    if age_box.store(25)
        print("Valid age: ${age_box.retrieve()}")
    
    if not age_box.store(200)
        print("Invalid age")

Key Takeaways

  • Generics enable type-safe reusable code — Write once, use with many types
  • Type parameters are like function parameters — Container(T) where T is filled in at use time
  • Collections are generics — List(T), HashMap(K, V) work with any type
  • Constraints limit generics — Use interfaces to require specific capabilities
  • Type erasure happens at compile time — Don't rely on runtime type information

Next Steps

  • → 14-Contracts — Express what generics must provide
  • → 15-Pipelines — Chain generic operations cleanly
  • → Project 1 — Use generics in real code

Generics are the bridge between flexibility and safety. Master them, and your code scales.