Generics
Every programming language has tools for handling the duplication of concepts. The most common form of duplication is code that works for one type but needs to be rewritten for another. Write a max function for int, then write it again for float, then again for String. The logic is identical -- only the type differs.
Generics eliminate this duplication. They let you write functions, types, and enums that are parameterized by type -- the same code works for any type the caller chooses. And because Auto (like Rust) uses monomorphization, there is zero runtime cost. The compiler generates specialized versions for each concrete type, so generics are as fast as hand-written code.
In this chapter, we'll cover:
- What Are Generics? -- Zero-cost abstraction, type parameterization
- Generic Functions --
<T>syntax, type inference - Generic Types --
type Box<T>, methods on generic types - Spec Bounds -- Constraining generics with specs
- Multiple Bounds -- Combining multiple spec constraints
- Generic Methods -- Methods that operate on generic types
- Monomorphization -- How generics become concrete code at compile time
- Generic Enums --
Result<T, E>,Option<T>patterns
What Are Generics?
Generics are abstract stand-ins for concrete types. Instead of writing a function that works with int specifically, you write a function that works with "some type T." The caller decides what T is when they call the function.
We have already used generics throughout this book. ?T (Option), !T (Result), List<T>, and Map<K, V> are all generic types. They are parameterized by one or more type parameters that determine what kind of data they hold. Now we will learn how to define our own.
The key insight is that generics are a compile-time abstraction. The compiler knows exactly which concrete types are used, and it generates optimized code for each one. There are no runtime type checks, no boxing, no downcasting. Generic code is just as fast as if you had written separate functions for each type by hand.
Generic Functions
A generic function is declared with type parameters in angle brackets <> between the function name and the parameter list.
This prints:
100
yWe read fn largest<T>(list []T) T as "the function largest is generic over some type T." The function takes a list of T values and returns a T. The type parameter T is a placeholder -- it will be replaced by a concrete type when the function is called.
The same function works for both [34, 50, 25, 100, 65] (a list of integers) and ['y', 'm', 'a', 'q'] (a list of characters). The compiler infers the type of T from the argument -- you do not need to write largest<int>(numbers) explicitly. This is called type inference.
Note what happens in C: because C has no generics, the compiler must generate separate largest_int and largest_char functions. This is essentially what Auto's compiler does automatically through monomorphization -- but you never have to write the separate versions yourself.
How this maps across languages:
- Rust uses the same
<T>syntax. The only difference is Rust's: Clone + PartialOrdbounds, which we will cover in the Spec Bounds section. - Python uses
TypeVar('T')for generic type variables. Python's generics are erased at runtime -- they are only checked by static type checkers like mypy. - C has no generics. The transpiler generates separate monomorphized functions per concrete type.
- TypeScript uses
<T>syntax similar to Auto and Rust. Generics are erased at compile time.
Generic Types
Types can also be parameterized by generic type parameters. This is how you build containers, wrappers, and other reusable data structures.
This prints:
42
helloBox<T> is a generic type with one type parameter T. The type parameter appears in the type definition (type Box<T>) and in the method extension (ext Box<T>). The new constructor takes a value of type T and returns a Box<T>. The get method returns the inner value of type T.
The same Box<T> definition works for both Box<int> (holding 42) and Box<String> (holding "hello"). The compiler generates separate concrete types for each variant.
The C transpiler shows this explicitly: BoxInt and BoxStr are separate structs with separate functions. In Auto and Rust, the compiler does this automatically.
You can use multiple type parameters when fields need different types:
type Pair<T, U> {
first T
second U
}Here T and U are independent type parameters. Pair<int, String> holds an integer and a string, while Pair<float, bool> holds a float and a boolean.
Spec Bounds
A generic function that uses > on its type parameter (as largest does) cannot accept just any type. The > operator requires the type to support comparison. In Auto, we express this constraint using spec bounds.
A spec bound restricts which types are valid for a type parameter. The syntax is where T: SpecName, which means "T must implement the spec named SpecName."
This prints:
(3, 4)The spec Printable defines a contract: any type that implements Printable must provide a to_string method that returns a String. The Point type implements this spec using ext Point as Printable.
The function print_it<T> has the bound where T: Printable. This means the compiler will only allow you to call print_it with types that implement Printable. If you tried to pass an int, you would get a compile-time error because int does not implement Printable.
In Rust, the same bound is written as <T: Printable>. In TypeScript, it is <T extends Printable>. In Python, it is item: Printable using Protocol. In C, there are no generics -- the transpiler generates separate functions per concrete type.
Multiple Bounds
A type parameter can be constrained by multiple specs at once. Use a comma to separate the bounds in the where clause.
The function show_and_copy<T> requires that T implements both Display and Clone. The where clause uses two separate bounds separated by a comma: where T: Display, T: Clone.
In Rust, the equivalent is <T: Display + Clone> -- the + combines bounds into one constraint. Auto's comma-separated syntax makes each bound explicit, which is easier to read when there are many constraints.
In TypeScript, intersection types (&) combine multiple constraints. In Python, Protocol does not support intersection directly, so the transpiler uses # type: ignore comments. In C, multiple interface constraints cannot be expressed -- each concrete type requires its own function.
Generic Methods
Methods on generic types use the same ext Type<T> syntax. The type parameter is declared after ext and before the type name, just like Rust's impl<T> Type<T>.
This prints:
2
1The swap method is defined on Pair<T> using ext Pair<T>. The T declared after ext tells the compiler that this method is defined for all possible T. The method takes &self (a reference to the pair), constructs a new Pair<T> with the fields swapped, and returns it.
Because Pair<T> uses a single type parameter, both first and second must be the same type. Pair<int> holds two integers, Pair<String> holds two strings. You cannot mix types in a single Pair<T> -- for that, use Pair<T, U> with two type parameters.
The C transpiler generates PairInt_swap -- a concrete function for the int variant. The compiler does this for every concrete type used with Pair<T>.
Monomorphization
Generics in Auto are zero-cost abstractions. There is no runtime penalty for using them. The compiler achieves this through a process called monomorphization.
Monomorphization works by generating a separate copy of the generic function or type for each concrete type that is used. If you call identity(42), the compiler generates an identity function specialized for int. If you call identity("hello"), it generates another version specialized for String.
This prints:
42
helloThe identity function is trivially simple -- it returns its argument. But the principle applies to any generic function, no matter how complex. The compiler generates a specialized version for each concrete type used.
The C code shows this most explicitly: identity_int and identity_str are separate functions with separate implementations. This is what Auto's compiler does internally. In the final binary, there is no "generic" identity function -- only the concrete versions.
This has an important implication: generics do not use dynamic dispatch or runtime type information. A call to identity(42) compiles down to the same machine code as if you had written a non-generic fn identity_int(x int) int by hand. The abstraction is free.
The tradeoff is that monomorphization can increase binary size if a generic function is used with many different types. Each concrete type produces a copy of the function. In practice, this is rarely a problem -- most generic functions are used with a handful of types.
How this maps across languages:
- Rust uses the same monomorphization strategy. The behavior is identical.
- Python does not monomorphize. There is only one
identityfunction at runtime, and it works with any type. This is flexible but slower -- Python must check types dynamically. - C has no generics, so the transpiler manually generates monomorphized functions. The result is the same as Auto's automatic monomorphization.
- TypeScript erases generics at compile time. The generated JavaScript has a single function that works with any type, similar to Python.
Generic Enums
Enums can also be parameterized by type parameters. The most important examples are Result<T, E> and Option<T>, which we have used throughout this book. Now we will see how they work from the inside.
This prints:
Result: 3.333333
Error: division by zeroResult<T, E> is a generic enum with two type parameters: T for the success value and E for the error value. The Ok variant carries a T, and the Err variant carries an E. The divide function returns Result<float, String> -- a float on success, or a string error message on failure.
The is expression (Auto's equivalent of Rust's match) destructures the Result and lets you handle each variant separately. The compiler ensures that you handle both Ok and Err -- if you forget one, it is a compile-time error.
This pattern is the foundation of error handling in Auto. Every operation that can fail returns a Result<T, E>, and the compiler forces you to handle the error case. There are no unchecked exceptions, no null pointer errors from forgotten error handling.
The C transpiler shows the underlying mechanism: a ResultFloat struct with a boolean tag (is_ok) and two value fields (only one of which is valid at a time). This is a tagged union -- the same data structure that Auto's enums compile down to internally.
How this maps across languages:
- Rust has the same
Result<T, E>enum in the standard library. The syntax and semantics are identical. - Python uses
Union[float, str]-- a union type. The transpiler usesisinstancechecks instead of pattern matching. - C uses a manually defined tagged union struct. The programmer must remember to check
is_okbefore accessing the value. - TypeScript uses a discriminated union type with a
tagfield. The pattern is the same as Auto'sisexpression.
Summary
Generics are Auto's mechanism for writing code that works across types without sacrificing performance or type safety. Here is the complete picture:
| Concept | Auto | Rust | TypeScript | Python |
|---|---|---|---|---|
| Generic function | fn foo<T>(x T) | fn foo<T>(x: T) | function foo<T>(x: T) | def foo(x: T) |
| Generic type | type Box<T> { value T } | struct Box<T> { value: T } | class Box<T> | class Box(Generic[T]) |
| Spec bound | where T: Printable | T: Printable | T extends Printable | Protocol |
| Multiple bounds | where T: A, T: B | T: A + B | T extends A & B | -- |
| Generic methods | ext Pair<T> { ... } | impl<T> Pair<T> { ... } | class method | class method |
| Generic enum | enum Result<T, E> | enum Result<T, E> | discriminated union | Union |
| Monomorphization | Compile-time | Compile-time | Erased | Erased |
The key takeaways:
Zero-cost abstraction: Generics compile down to the same machine code as hand-written type-specific functions. There is no runtime overhead.
Type inference: In most cases, you do not need to specify the type parameters explicitly. The compiler infers them from the arguments.
Spec bounds: Constraints on type parameters ensure that generic code can only be used with types that provide the required functionality.
Monomorphization: The compiler generates a separate copy of each generic function for every concrete type used. This produces fast, optimized code at the cost of slightly larger binaries.
Generic enums:
Result<T, E>andOption<T>are the foundation of error handling and null safety in Auto.
In the next chapter, we will bring together everything we have learned in the Phase 2 capstone project.