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

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)—Tis a type parameterContainer(int)()— substituteintforT, then create an instance- Inside the class,
Tcan 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,
Tis a regular type — usable in annotations, casts, and return types. - At the call site the type argument is required:
identity(int)(42). A bareidentity(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 asunknown type 'T', with the generic spelling as the hint. - Under the hood the compiler emits a
comptime T: typeparameter 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 notcue compare. The interface above is a constraint you declare and you call (item.compareTo(max)). The language's own ordering protocol is thecomparecue (Chapter 07, "Cues"):<,<=,>,>=andlist.sort()callcue compare, and they do not look atcompareTo.Score(1) < Score(2)does not build with only the interface. If you want both, give the class both; the cue's body can simplyreturn .compareTo(other). Like everycue compare, it needs acue equalson 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.
PositiveIntis justintin the generated Zig — no wrapper struct, no runtime overhead beyond the constraint check. valueis the implicit binding in thewhereclause — refers to the variable being declared.- Check at initialization. The compiler emits a runtime check after
every
var x: AliasType = exprdeclaration. If the constraint fails, the program panics with the alias name in the message. --turbostrips 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 in0..100, ages in0..150. - Self-documenting parameter types —
Bounded(0, 100)reads better thanintplus 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.