Parametric Polymorphism

The long name means something quite simple: one piece of code that works for many types. You have been using it already — len works on arrays, slices, maps and strings; append works on any dynamic array. This lesson is about writing such code yourself.

Odin's version of the feature is deliberately modest. There are no template metaprogrammes, no angle brackets, and no separate generics syntax to learn: a type parameter is a name with a dollar sign in it, and a constraint is written with the where you already know from ordinary conditions.

Why Parametric Polymorphism

Two procedures with the same body and different types are a sign that something is missing from the language.

The Duplication Problem

Suppose you want the largest value in a slice. Without type parameters you need one procedure per element type:

// The same five lines, once per type. Four types, four copies to keep in step.
max_int :: proc(values: []int) -> int { /* ... */ }
max_f32 :: proc(values: []f32) -> f32 { /* ... */ }
max_f64 :: proc(values: []f64) -> f64 { /* ... */ }
max_u8  :: proc(values: []u8)  -> u8  { /* ... */ }

Every copy is a place for the logic to drift, and a place to forget a fix. The bodies are identical; only the types differ — which is exactly the situation parametric polymorphism exists for.

One Procedure, Many Types

Replace the concrete type with a type parameter and the duplication disappears:

// One body. `$T` stands for whatever type the caller uses.
max_value :: proc(values: []$T) -> T {
    best := values[0]
    for v in values[1:] {
        if v > best {
            best = v
        }
    }
    return best
}

fmt.println(max_value([]int{3, 9, 4}))     // 9
fmt.println(max_value([]f64{1.5, 0.5}))    // 1.5

The dollar sign is the whole of Odin's new syntax. Read $T as "some type, to be decided by the call". The compiler works out which type from the arguments you pass, exactly as it works out the type of x := 42 without being told.

One generic procedure taking a $T parameter, from which the compiler produces separate specialised versions for int, f64 and u8 at compile time
One source procedure, one compiled version per type your program actually uses. There is no runtime type check and no dispatch table.

Generic Procedures

The mechanics are three small things: where the $ goes, how constraints are written, and how a call resolves the type.

The $T Parameter

Put $ before a type name in the parameter list and that name becomes a type parameter. Here is a real one from the standard library, quoted verbatim:

// From core:slice — `^$T` binds T wherever the type appears.
from_ptr :: proc "contextless" (ptr: ^$T, count: int) -> []T {
    return ([^]T)(ptr)[:count]
}

Notice the pattern: the dollar sign appears on the first use, and every later use is the bare name — []T in the result, [^]T in the body. You are naming a type, then using that name like any other type in the language.

There is also a slash form that binds two names at once: the container and its contents. From the same file:

// `$T/[]$E` means: T is the whole slice type, and E is its element type.
swap :: proc(array: $T/[]$E, a, b: int) { ... }

That form is worth recognising when you read library code, because it lets a procedure talk about "the slice" and "the thing inside the slice" separately — which is exactly what a swap needs.

Constraints with where

A type parameter with no constraint can be any type, which means the body has to be legal for whichever type you actually call it with. A body that compares with > is fine for numbers and wrong for a type that has no ordering — so stating your requirement up front turns a puzzling failure into a clear one.

That is what a where clause does, and it reads like a condition because it is one. Here is a standard-library procedure that compares with ==, so it asks for a comparable element type:

// From core:slice — the body uses ==, so the constraint says "comparable".
linear_search :: proc "contextless" (array: $A/[]$T, key: T) -> (index: int, found: bool)
    where intrinsics.type_is_comparable(T) {
    for x, i in array {
        if x == key {
            return i, true
        }
    }
    return -1, false
}

And here is one that asks for numbers instead, because its body multiplies:

// From core:slice — the body multiplies, so the constraint says "numeric".
dot_product :: proc "contextless" (a, b: $S/[]$T) -> (r: T, ok: bool)
    where intrinsics.type_is_numeric(T) {
    if len(a) != len(b) {
        return
    }
    #no_bounds_check for _, i in a {
        r += a[i] * b[i]
    }
    return r, true
}

Read a constraint as a promise travelling in both directions. To the compiler: everything in this body is legal for any type satisfying this. To the reader: here is precisely what this procedure needs from its type. Several constraints can be listed, separated by commas, which is how the standard library asks for an integer that is also unsigned:

// Two constraints, comma separated.
log2 :: proc "contextless" (x: $T) -> (res: T)
    where intrinsics.type_is_integer(T), intrinsics.type_is_unsigned(T)

The placement is fixed and easy to remember: where comes after the results, immediately before the body. It is the same keyword you will meet again with polymorphic types, and the same keyword you would use to express a condition anywhere else.

Calling: Inference and Explicit Types

Most calls say nothing about types at all, because the type can be read off the arguments:

// Inference: T is int here, because the slice holds ints.
fmt.println(max_value([]int{3, 9, 4}))       // 9

// And here T is f64, for the same reason.
fmt.println(max_value([]f64{1.5, 0.5}))      // 1.5

When a type cannot be inferred — because it appears nowhere in the arguments — you pass it explicitly, as a value of type typeid. The standard library's own documentation examples show both styles, quoted verbatim:

// From the docs of core:slice — the type argument is passed positionally.
small_items := slice.reinterpret([]i32, large_items)

// And here the type names the set type to build.
bs := slice.enum_slice_to_bitset(my_flag_slice, rl.ConfigFlags)

So the rule is short: types that can be inferred are inferred, and types that cannot are written where the parameter sits. There is no special angle-bracket syntax for "the type argument list" — a typeid parameter is an ordinary parameter that happens to receive a type.

Generic Types

Procedures are only half the story. Types can take type parameters too, and this is how containers are written in Odin.

Parameterised Structs and Unions

The parameters go in parentheses between the keyword and the body. The clearest example is Odin's own demo file, which builds a "maybe" — a union that either holds a value or holds nothing:

// Quoted from Odin's demo file: a union parameterised by its payload type.
Maybe :: union($T: typeid) {T}

// Written with a concrete type at the point of use:
i: Maybe(u8)
p: Maybe(^u8)      // for pointers, nil is the sentinel value

Read Maybe(u8) as "the Maybe type, instantiated for u8" — the same idea as a generic procedure, applied to a type instead of a call. And the comment in the original is worth keeping in mind: for a pointer payload no tag is needed at all, because nil already means "nothing is there".

Structs follow exactly the same shape, and that is how container types are written: a queue, a tree, a cache, a grid — each parameterised by whatever it holds.

The typeid/… Form

The slash form appears in type parameters as well, and it is how a procedure says "give me a type, and it must be one of these". Two verified examples from the standard library:

// From core:slice — T receives a whole slice type; U is its element type.
reinterpret :: proc "contextless" ($T: typeid/[]$U, s: []$V) -> []U

// And here T must be a bit set over the same element type E as the slice.
enum_slice_to_bitset :: proc "contextless" (enums: []$E, $T: typeid/bit_set[E]) -> (bits: T)
    where intrinsics.type_is_enum(E), intrinsics.type_bit_set_elem_type(T) == E

The second one is worth reading slowly, because it shows how much a constraint can express. T must be a bit set whose element type is the same E as the slice you passed. That ties the two arguments together: they cannot be mismatched, and no runtime check is involved — the compiler proves it while the program is being built.

Asking the Compiler About Types

Every constraint in this lesson is a question asked about a type, and the answers come from base:intrinsics — the compiler's own toolbox. You have met one of its members already, in size_of.

The type_is_* Family

These are the questions you will meet most often, spelled as the standard library spells them:

IntrinsicTrue when the type…
intrinsics.type_is_integer(T)is an integer
intrinsics.type_is_unsigned(T)is an unsigned integer
intrinsics.type_is_numeric(T)is a number — integer or floating point
intrinsics.type_is_comparable(T)can be compared with ==
intrinsics.type_is_enum(E)is an enumeration
intrinsics.type_is_enumerated_array(T)is an array indexed by an enum

All six refer to ideas from earlier in the track — enums from the structs lesson, comparison from the operators lesson, enum-indexed arrays from the structs lesson. A constraint is nothing more than asking the compiler to confirm one of those properties before the body is allowed to depend on it.

Questions About Structure

Two more intrinsics answer a different kind of question: not "what is this type like?" but "what is inside it?"

// From core:slice:
//   intrinsics.type_elem_type(T)         — the element type of an array or slice
//   intrinsics.type_bit_set_elem_type(T) — the element type of a bit set

Both turn up in return types and constraints, which is how a result type can be computed from an argument type. It is the last piece of the mechanism: a generic procedure can name a type it has never seen, and let the compiler work out what it is.

Generic Procedure Groups

You met procedure groups in the procedures lesson: one name, a list of implementations, the compiler choosing the one that fits. They work with generics as well, and the standard library uses them to hide a decision the caller should not have to make.

One Name, Several Implementations

// Quoted from core:slice — two implementations behind one name.
bitset_to_enum_slice :: proc{bitset_to_enum_slice_with_make, bitset_to_enum_slice_with_buffer}

Behind that name are two procedures: one allocates a buffer for you, and one fills a buffer you supply. Both are generic over the set type and the element type. A caller who writes slice.bitset_to_enum_slice(bs) gets whichever version matches the arguments they passed — one concept, one name, and the choice of strategy made where it belongs.

Resolution Is Explicit

Two properties of that mechanism are worth holding on to. A group must be declared — Odin will not quietly accept two same-named procedures, so you can never arrive at an overloaded name by accident. And when no member of the group fits your call, the error appears at the call site, naming what you gave it.

That is the same principle as the explicit conversions in the types lesson: nothing in Odin converts, resolves, or guesses silently. A call either matches something exactly, or it does not compile.

What Generics Do Not Do

Two things Odin's parametric polymorphism deliberately is not, and one practical consequence worth knowing before you reach for it everywhere.

No Text Substitution

  • Not a preprocessor. Nothing is pasted into your source as text. A $T is a type name the compiler understands, so there is no separate expansion step that could produce code you cannot read in your editor or step through in a debugger.
  • Not runtime polymorphism. There is no dispatch table and no type tag being tested while the program runs. The versions are resolved during compilation, so a call into a generic procedure is as direct as any other call.

And the practical consequence: each distinct type your program uses produces its own compiled version. Use a generic procedure with four types and the binary contains four versions. That is usually exactly what you want — it is what makes the call direct and the optimisation possible — but it also means generics are not a way to make a program smaller.

When you want one implementation shared by many types at runtime, the tool is a union rather than a type parameter. So ask which kind of flexibility the problem needs — compile-time or runtime — because they are different problems, and Odin keeps them separate on purpose.

Where This Goes Next

The next lesson takes the same idea further: telling the compiler to evaluate things while it builds your program, so that whole branches and whole computations simply do not exist by the time it runs.

The Page in One Breath

  • $T names a type. The compiler infers it from the arguments, or you pass a typeid explicitly.
  • The slash form $T/[]$E binds the container and its element type together.
  • A where clause states what the type must support; several constraints are comma separated, and it sits after the results.
  • Constraints come from base:intrinsics — type_is_integer, type_is_comparable, type_is_enum and friends.
  • Types take parameters too: union($T: typeid) {T} is how the standard library writes a "maybe".
  • type_elem_type and type_bit_set_elem_type let a result type be computed from an argument type.
  • Procedure groups give one name to several implementations, and resolution is always explicit.
  • No preprocessor and no runtime dispatch: there is one compiled version per type your program actually uses.
Well done. A good exercise: write max_value from the start of this page, then write a second version whose constraint uses intrinsics.type_is_numeric, and call each with a slice of int and a slice of f64. Then call the numeric one with a slice of strings and read the error — the message will show you exactly what the constraint bought you.

Continue with Compile-time Programming →