Skip to content

Chapter 10: Generics

Writing Code Once, Using It Everywhere

You've written functions that work with specific types:

wyn
fn find_int(arr: [int], target: int) -> Option<int> {
    for (var i = 0; i < arr.len(); i = i + 1) {
        if (arr[i] == target) {
            return some(i)
        }
    }
    return none
}

This works for integers. But what about strings? Floats? Custom types? Do you copy-paste the function and change the types?

No. You use generics.

Generic Functions

A generic function works with any type:

wyn
fn find<T>(arr: [T], target: T) -> Option<int> {
    for (var i = 0; i < arr.len(); i = i + 1) {
        if (arr[i] == target) {
            return some(i)
        }
    }
    return none
}

fn main() -> int {
    var numbers = [1, 2, 3, 4, 5]
    var strings = ["hello", "world", "wyn"]
    
    var idx1 = find(numbers, 3)      // T = int
    var idx2 = find(strings, "wyn")  // T = string
    
    return 0
}

The <T> after the function name declares a type parameter. T is a placeholder for any type. When you call find(numbers, 3), the compiler infers T = int. When you call find(strings, "wyn"), it infers T = string.

This is called monomorphization. The compiler generates a separate version of the function for each type you use it with. There's no runtime overhead-generic code is as fast as hand-written code for each type.

Generic Structs

Structs can be generic too:

wyn
struct Pair<T, U> {
    first: T,
    second: U
}

fn main() -> int {
    var int_pair = Pair { first: 1, second: 2 }
    var mixed = Pair { first: "hello", second: 42 }
    var string_pair = Pair { first: "foo", second: "bar" }
    
    print(int_pair.first)   // 1
    print(mixed.first)      // "hello"
    print(mixed.second)     // 42
    
    return 0
}

Pair<T, U> has two type parameters. You can mix and match types however you want.

Result and Option Are Generic

You've been using Result and Option. They're generic types:

wyn
enum Result<T, E> {
    ok(T),
    err(E)
}

enum Option<T> {
    some(T),
    none
}

When you write Result<int, string>, you're instantiating the generic Result type with T = int and E = string.

This is why Result and Option work with any types-they're generic.

Type Aliases

Sometimes generic types get verbose:

wyn
fn process_user(result: Result<Option<User>, DatabaseError>) -> int {
    // ...
}

Type aliases make this cleaner:

wyn
type UserResult = Result<Option<User>, DatabaseError>

fn process_user(result: UserResult) -> int {
    // ...
}

Type aliases don't create new types-they're just names for existing types. But they make code more readable.

Common Type Aliases

wyn
type UserId = int
type Email = string
type Timestamp = int

fn create_user(id: UserId, email: Email, created: Timestamp) -> User {
    // ...
}

This documents intent. A UserId is semantically different from a random integer, even though they're the same type to the compiler.

Generic Methods

Methods on a generic struct can use its type parameters:

wyn
struct Container<T> {
    value: T
}

fn Container.describe(self) -> string {
    return "Container(${self.value})"
}

fn main() -> int {
    var int_container = Container { value: 42 }
    var string_container = Container { value: "wyn" }
    
    print(int_container.describe())     // "Container(42)"
    print(string_container.describe())  // "Container(wyn)"
    
    return 0
}

The describe method works for a Container<int> and a Container<string> alike-T is whatever the struct was instantiated with. (Method-level type parameters of their own, like a map<U> that changes the element type, aren't supported yet.)

When to Use Generics

Use generics when:

  • You're writing container types (arrays, maps, trees)
  • You're writing algorithms that work on any type (sorting, searching)
  • You're writing utility functions (identity, swap, compare)
  • You want to avoid code duplication

Don't use generics when:

  • The function only makes sense for one type
  • The generic version is harder to understand
  • You need type-specific behavior

Examples:

wyn
// Good: generic makes sense
fn identity<T>(x: T) -> T {
    return x
}

fn swap<T>(a: T, b: T) -> (T, T) {
    return (b, a)
}

// Bad: only makes sense for numbers
fn add<T>(a: T, b: T) -> T {
    return a + b  // What if T is a string?
}

// Better: specific type
fn add(a: int, b: int) -> int {
    return a + b
}

Generic Collections

Arrays, HashMaps, and HashSets are generic:

wyn
var numbers: [int] = [1, 2, 3]
var strings: [string] = ["a", "b", "c"]

var int_map: HashMap<string, int> = {"a": 1, "b": 2}
var string_map: HashMap<string, string> = {"a": "x", "b": "y"}

var int_set = {:1, 2, 3}         // HashSet<int>
var string_set = {:"a", "b", "c"} // HashSet<string>

Usually you don't need to write the types-the compiler infers them (set literals are always inferred). But sometimes an annotation helps with clarity.

Real-World: Generic Stack

wyn
struct Stack<T> {
    items: [T]
}

fn Stack.push(self, item: T) {
    self.items = self.items + [item]
}

fn Stack.pop(self) -> Option<T> {
    if (self.items.len() == 0) {
        return none
    }
    var item = self.items[self.items.len() - 1]
    // Remove last item (simplified)
    return some(item)
}

fn Stack.is_empty(self) -> bool {
    return self.items.len() == 0
}

fn main() -> int {
    var int_stack = Stack { items: [1, 2] }
    int_stack.push(3)
    
    var top = int_stack.pop()
    match top {
        some(value) => print(value),  // 3
        none => print("Empty")
    }
    
    var string_stack = Stack { items: ["hello"] }
    string_stack.push("world")
    print(string_stack.is_empty())  // false
    
    return 0
}

One implementation, works with any type.

Real-World: Generic Cache

wyn
struct Cache<K, V> {
    data: HashMap<K, V>
}

fn Cache.get(self, key: K) -> Option<V> {
    if (self.data.has(key)) {
        return some(self.data[key])
    }
    return none
}

fn Cache.set(self, key: K, value: V) {
    self.data[key] = value
}

fn Cache.has(self, key: K) -> bool {
    return self.data.has(key)
}

struct User {
    name: string,
    age: int
}

fn main() -> int {
    var user_cache = Cache { data: {} }
    user_cache.set("alice", User { name: "Alice", age: 30 })
    
    var user = user_cache.get("alice")
    match user {
        some(u) => print(u.name),
        none => print("Not found")
    }
    
    return 0
}

Constraints (Future Feature)

Sometimes you need to constrain what types can be used:

wyn
// Hypothetical syntax (not yet in Wyn)
fn max<T: Comparable>(a: T, b: T) -> T {
    if (a > b) {
        return a
    }
    return b
}

The T: Comparable constraint means T must support comparison operators. This prevents you from calling max with types that can't be compared.

Wyn doesn't have constraints yet, but they're planned. For now, you rely on the compiler to catch type errors when you use generic functions.

Performance Considerations

Generics have zero runtime cost. The compiler generates specialized code for each type:

wyn
fn identity<T>(x: T) -> T {
    return x
}

var a = identity(42)        // Generates identity_int
var b = identity("hello")   // Generates identity_string

This is called monomorphization. It's the same approach Rust uses. The downside is larger binary size-each instantiation adds code. The upside is maximum performance.

Generic Best Practices

Use Descriptive Type Parameter Names

wyn
// Good: clear meaning
fn map<T, U>(arr: [T], f: fn(T) -> U) -> [U]

// Bad: unclear
fn map<A, B>(arr: [A], f: fn(A) -> B) -> [B]

Convention: T for "type", U for "second type", K for "key", V for "value", E for "error".

Don't Over-Generalize

wyn
// Bad: too generic
fn process<T, U, V, W>(a: T, b: U, c: V) -> W {
    // What does this even do?
}

// Good: specific and clear
fn format_user(user: User, template: string) -> string {
    // Clear purpose
}

Document Generic Functions

wyn
// Finds the first occurrence of target in arr.
// Returns Some(index) if found, None otherwise.
fn find<T>(arr: [T], target: T) -> Option<int> {
    // ...
}

What You've Learned

Generics let you write code that works with any type. Functions, structs, and methods can all be generic.

Type parameters are placeholders for concrete types. The compiler infers them from usage or you can specify them explicitly.

Monomorphization generates specialized code for each type. This gives you zero-cost abstraction-generic code is as fast as hand-written code.

Type aliases make complex generic types more readable. Use them to document intent and reduce verbosity.

Result and Option are generic types. That's why they work with any types.

Exercises

  1. Generic swap: Write a generic function that swaps two values and returns them as a tuple.

  2. Generic min/max: Write generic functions to find the minimum and maximum of two values. What happens when you try to use them with strings?

  3. Generic container: Implement a generic Box<T> type that wraps a single value. Add methods to get and set the value.

  4. Generic filter: Write a generic function that filters an array based on a predicate function.

  5. Type aliases: Create type aliases for common Result and Option combinations in a hypothetical web application (e.g., UserResult, PostResult).

  6. Generic tree: Design (don't fully implement) a generic binary tree structure. What methods would it need?

  7. Refactor: Take code that duplicates logic for different types and refactor it to use generics.


Next: Chapter 11: Extension Methods

MIT License - v1.19.1