Generics & Type Classes

Generics let one implementation serve many types without boxing or runtime checks, because the compiler instantiates a specialized version for each type you use. This lesson shows the syntax, the constraints that keep generic code readable, and the type classes that describe what a type must support.

Generic Procedures and Types

Bracket the type parameter after the name: proc f[T](...). The compiler infers T from the arguments in almost every case, so generic code rarely needs explicit instantiation.

A generic procedure instantiated once per concrete type argument

Figure 1 — the definition is written once; the compiler produces a separate, fully typed copy for every type argument used.

One Implementation, Many Types

proc first[T](items: openArray[T]): T =
  ## openArray accepts both arrays and sequences without copying.
  items[0]

echo first([10, 20, 30])          # 10 — T inferred as int
echo first(@["a", "b"])           # a  — T inferred as string
# echo first([])                  # an empty literal gives the compiler nothing to infer

proc swap[T](a, b: var T) =
  let temp = a
  a = b
  b = temp

var x = 1
var y = 2
swap(x, y)                        # works for any type, not just int
echo x, " ", y                    # 2 1

Generic Types

type
  Stack[T] = object               # a container specialized on demand
    items: seq[T]

proc push[T](s: var Stack[T], value: T) =
  s.items.add(value)

proc pop[T](s: var Stack[T]): T =
  ## Caller must check 'isEmpty' first; popping an empty stack is a defect.
  result = s.items[^1]
  s.items.setLen(s.items.len - 1)

proc isEmpty[T](s: Stack[T]): bool = s.items.len == 0

var numbers: Stack[int]           # T = int for this variable, fixed at compile time
numbers.push(7)
numbers.push(9)
echo numbers.pop()                # 9 — last in, first out
echo isEmpty(numbers)             # false

var words: Stack[string]          # the same code, a different element type
words.push("go")
echo words.pop()                  # go

Type Constraints and Concepts

An unconstrained T accepts everything, which is rarely what you want. Nim's constraint syntax after the colon names either a concrete type, a type class from the standard library, or a concept you define yourself.

Constraining the Type Parameter

proc total[T: SomeNumber](items: openArray[T]): T =
  ## 'SomeNumber' is a type class covering int, float and their sized variants.
  ## Without the constraint, 'result += item' would not compile for every T.
  for item in items:
    result += item

echo total([1, 2, 3])             # 6    — T is int
echo total([1.5, 2.5])            # 4.0  — T is float
# echo total([true, false])       # compile error: bool is not a SomeNumber

proc factorial(n: Natural): int =
  ## 'Natural' is a range type (int >= 0), so no runtime check is needed here.
  result = 1
  for i in 2 .. n:
    result *= i

echo factorial(5)                 # 120
# let bad: Natural = -1           # RangeDefect at runtime if the value is dynamic

Concepts: Structural Contracts

A concept states the operations a type must support. Nothing is registered or inherited: if the type can do what the concept lists, it matches.

type
  Printable = concept x            # 'concept x' names the parameters it describes
    $x is string                   # any type whose '$' yields a string matches

proc describe[T: Printable](value: T): string =
  "value = " & $value              # the concept guarantees '$' exists

echo describe(42)                  # value = 42
echo describe(1.5)                 # value = 1.5
# A type without a '$' operator is rejected at compile time, with the reason.

Compile-Time Branching with 'when'

Inside a generic body, when runs at compile time and only the surviving branch is compiled for each instantiation. That is how library code specializes behaviour without paying for runtime dispatch.

proc double[T](value: T): T =
  when T is SomeInteger:           # exact integer arithmetic
    value * 2
  elif T is SomeFloat:             # floating point path
    value * 2.0
  else:
    {.error: "double supports numbers only".}   # rejected while compiling

echo double(21)                    # 42
echo double(1.5)                   # 3.0
# echo double("x")                 # compile-time error from the {.error.} branch

Generics in Practice

Most generic code you will write comes from three places: standard-library type classes in parameter lists, containers you define once and reuse, and the deliberate choice between generics and templates for compile-time abstraction.

Type Classes from the Standard Library

import std/algorithm

var data = @[3, 1, 2]
data.sort                         # generic over any comparable element type
echo data                         # @[1, 2, 3]

proc total(values: varargs[int]): int =
  ## 'varargs' accepts any number of arguments of one type.
  for value in values:
    result += value

echo total(1, 2, 3)               # 6
echo total()                      # 0 — zero arguments is valid
# echo total(1, "2")              # compile error: the elements must all be int

Generic or Template?

  • Generic (proc f[T]): a real procedure with a type parameter. It is type-checked at the definition, debuggers see it, and recursion works. Prefer this by default.
  • Template (template t(x: untyped)): textual expansion with hygiene — the argument is substituted, not evaluated first. Use it for control-flow helpers, logging wrappers and zero-cost sugar, never as a substitute for a procedure.
  • Rule of thumb: if you need the argument's value, use a generic; if you need its syntax (an expression that must not be evaluated, like the body of a logger), use a template or a macro.

Review Checklist

  1. Add the narrowest constraint that still fits your callers — SomeNumber before SomeInteger, SomeInteger before int.
  2. Prefer openArray[T] over seq[T] for read-only parameters: it accepts arrays, sequences and slices without copying.
  3. Keep generic bodies short. A generic function that is hard to read is hard to debug, because errors are reported at the instantiation site.
  4. Write one concrete test per instantiation you actually ship — the compiler proves the types fit, not that the results are right.