---
title: "Statically-dispatched typeclasses in C (without some jank manual registry)"
description: "Or: How I stopped worrying and learned to count to two million in the preprocessor."
published: 2026-10-05
canonical: https://emskeirik.dev/blog/typeclasses-in-c/
tags: ["C","Metaprogramming","Type Systems"]
---

# Statically-dispatched typeclasses in C (without some jank manual registry)

> Or: How I stopped worrying and learned to count to two million in the preprocessor.

So. C has no <a href="https://en.wikipedia.org/wiki/Ad_hoc_polymorphism" target="_blank" rel="noopener noreferrer">ad-hoc polymorphism</a>.

No generics, no overloading, no traits, no inheritance. There is no way of getting a function to work differently for different types, selecting the appropriate behavior based on the type. Statically, at least.

Have you ever wondered what it could be like if you could use a typeclass or a trait in C, and it wasn't just a funky vtable wrapper? Without having to manually maintain some giant ugly X-macro table in a file somewhere?

Well, my friend. This is where things get **blursed**.

I wrote <a href="https://github.com/elias-michaias/c-trait" target="_blank" rel="noopener noreferrer">`c-trait`</a> to see how far I could push it. Define some operations, implement them for unrelated concrete types, and let the compiler pick from the receiver’s type. Static dispatch is the default. There is also an opt-in `dyn` path for the cases where the concrete type needs to disappear.

This should feel slightly illegal. I made it anyway. Go ahead, piss your pants; then look at the assembly.

## The blessed half

A small trait declaration is just a signature macro followed by another inclusion of `trait.h`:

```c
#include <stdio.h>
#include <trait.h>

#define Trait Greet
#define GreetSignature(Self) \
  required(Self, void, greet)
#include <trait.h>

typedef struct {
  const char *name;
} Person;

#define For Person
#define Impl Greet
void def(greet) {
  printf("Hello, %s!\n", self->name);
}
#include <trait.h>

int main(void) {
  Person person = { .name = "World" };
  call(Greet.greet, &person);
}
```

`Greet.greet` looks like an ordinary member access, but it is a selector value with a unique generated type. `call()` combines that selector type with the concrete type behind `&person`, feeds the pair to the active compile-time dispatch engine, and selects the wrapper for `Person_Greet_greet`.

The table in this `Person` example is the generated `Greet` selector table:

```c
typedef struct {
  ___sel_Greet_greet_t greet;
} Greet___sel_t;

static const Greet___sel_t Greet = {0};
```

Each field has a unique selector-tag type. The field’s value is irrelevant: `Greet.greet` is consumed inside an unevaluated `typeof` expression so the compiler can select a function by type. Nothing ever reads from or writes to the actual table at runtime.

Even trivial optimization removes the object entirely. In the worst unoptimized case, it has static storage duration and occupies a couple of bytes in the program’s read-only data section (`.rodata` on ELF). It is not a hidden runtime vtable; it is a small piece of type syntax wearing a struct’s clothes.

After optimization, the static route is a direct function call. There is no trait object, no function-pointer load, and no branch over registered implementations. If `Person` does not implement `Greet`, compilation fails.

The observant programmer may notice that this is starting to sound pretty similar to Rust. Syntactically, the C version is not much clunkier:

```rust
trait Greet {
    fn greet(&self);
}

struct Person {
    name: &'static str,
}

impl Greet for Person {
    fn greet(&self) {
        println!("Hello, {}!", self.name);
    }
}

fn greet<T: Greet>(value: &T) {
    value.greet();
}
```

The practical call-site difference is `value.greet()` in Rust versus `call(Greet.greet, value)` in C. The C spelling adds an explicit selector, but no manual vtable setup or cast. Rust’s compiler owns its trait system and monomorphizes `greet::<Person>`; in `c-trait`, the selector/receiver pair performs the compile-time selection and still lands on a concrete function. The syntax ends up surprisingly close even though a header cannot recreate Rust’s coherence rules, bounds, or borrow checker.

The <a href="https://github.com/elias-michaias/c-trait" target="_blank" rel="noopener noreferrer">`c-trait` repository</a> contains the header, runnable examples, benchmarks, and the full machinery behind this small surface.

## Why even care?

At this point someone usually says: use C++. Fair. Sometimes that is the right answer. It is not the answer I wanted.

Rebuilding this as an object hierarchy is not worth entertaining. I wanted one operation to vary by type, not a family tree, access specifiers, constructors, and a small theology of ownership. Object-oriented programming is a lot of furniture to drag in for a room I was trying to keep empty.

I wanted type-driven functionality without changing the data model. A `Person` should be allowed to remain a plain old struct: no base object, no embedded vtable pointer, no constructor discipline, and no obligation to join an inheritance hierarchy before some shared operation can apply to it. The implementation should be selected from the type I already have, not from architectural scaffolding added for the interface.

That also rules out the usual C interface pattern as the default. COM-style interfaces, GObject, and plenty of home-grown `ops` structs all reach for dynamic dispatch: package a data pointer beside a function-table pointer and accept an indirect call for every operation. It works. It also imposes the vtable load, indirect branch, and frequent loss of inlining automatically, even when the concrete type is sitting right there at compile time.

That was a no-go for me. I wanted static dispatch to be the ordinary case and type erasure to be an explicit escape hatch. Keep the values as plain data, keep the functions independently defined, and let the compiler erase the interface when it has enough information.

But if you really want to see something possibly even more blursed, you're always welcome to check out my <a href="https://github.com/elias-michaias/cpp-trait" target="_blank" rel="noopener noreferrer">`cpp-trait` library</a> :')

## Genesis: from snippets to selector tags

This started with Jackson Allan’s <a href="https://github.com/JacksonAllan/CC/blob/main/articles/Better_C_Generics_Part_1_The_Extendible_Generic.md" target="_blank" rel="noopener noreferrer">“Better C Generics: The Extendible Generic”</a> for `CC.h`. I wanted to take the technique and make it reproducible as a general mechanism rather than something I had to rebuild around each polymorphic function.

### What CC.h is

<a href="https://github.com/JacksonAllan/CC" target="_blank" rel="noopener noreferrer">Convenient Containers</a>, or CC, is a header-only generic container library for C. It has vectors, linked lists, maps, sets, ordered variants, and strings. The API looks startlingly normal:

```c
#include "cc.h"

int main(void) {
  vec(int) numbers;
  init(&numbers);
  push(&numbers, 5);
  push(&numbers, 8);

  map(int, float) weights;
  init(&weights);
  insert(&weights, 5, 0.5f);

  cleanup(&weights);
  cleanup(&numbers);
}
```

The declaration carries the element and key types. Calls such as `push(&numbers, 5)` and `insert(&weights, 5, 0.5f)` recover those types from the container pointer, so CC can expose one short API instead of `int_vec_push`, `int_float_map_insert`, and a new generated name for every combination.

This gets more interesting with user-defined types. CC lets a type register its own hash, comparison, and destructor behavior by defining `CC_HASH`, `CC_CMPR`, or `CC_DTOR` and including `cc.h` again. The container operation then selects the right function from the key or element type. No callback has to be supplied at every call.

That type-to-function registry is the part of CC I cared about.

Allan’s article works toward a counter-based extendible `_Generic`. The counter is the good part. Three octal digits hold the number of registered pairs, and a small family of recursive macros expands exactly that many associations:

```c
#define HASH_COUNT \
  CAT_4(0, HASH_COUNT_D3, HASH_COUNT_D2, HASH_COUNT_D1)

#define HASH_SLOT(n) \
  CAT_3(hash_, n, _ty): CAT_3(hash_, n, _fn),

#define R1_1(d3, d2) \
  HASH_SLOT(CAT_4(0, d3, d2, 0))
#define R1_2(d3, d2) \
  HASH_SLOT(CAT_4(0, d3, d2, 1)) R1_1(d3, d2)
#define R1_3(d3, d2) \
  HASH_SLOT(CAT_4(0, d3, d2, 2)) R1_2(d3, d2)

#define HASH_SLOTS \
  CAT_2(R1_, HASH_COUNT_D1)(HASH_COUNT_D3, HASH_COUNT_D2) \
  CAT_2(R2_, HASH_COUNT_D2)(HASH_COUNT_D3) \
  CAT_2(R3_, HASH_COUNT_D3)

#define hash(value) _Generic((value), HASH_SLOTS)(value)
```

`R1_3` emits slot 2, then delegates to `R1_2`, which emits slot 1 and delegates to `R1_1`. The higher `R2_*` and `R3_*` layers do the same thing for the next octal digits. `HASH_COUNT` therefore selects a finite unrolled expansion from slot zero through the current registration.

Registration supplies a pair and includes `add_hash.h`:

```c
size_t hash_long(long value) {
  return value * 2654435761ull;
}

#define HASH long, hash_long
#include "add_hash.h"
```

That header turns `HASH` into `hash_0000_ty` and `hash_0000_fn` at the initial `HASH_COUNT`, then advances the octal digits. The next expansion of `HASH_SLOTS` now contains the new association. Nobody reopens the `_Generic` list by hand.

The boundary is in the names: this is a registry for `hash()`. Its counter is `HASH_COUNT`, its emitted associations are `HASH_SLOTS`, and its registration path produces `hash_N_ty` / `hash_N_fn`. A second polymorphic function needs a second registry: another counter, another recursive slot family, another wrapper namespace, and another registration header. Allan explicitly notes that multiple extendible `_Generic` macros require adjusting the machinery to support multiple counters.

My first attempt to generalize that was spectacularly manual. I made snippet keys in my IDE that generated giant headers containing hundreds of entries for one function at a time. The generated shape was effectively:

```c
/* generated foo.h */
#define foo(value) _Generic((value), \
  FOO_ENTRY_000 \
  FOO_ENTRY_001 \
  FOO_ENTRY_002 \
  /* ...hundreds more... */ \
  FOO_ENTRY_511 \
)(value)
```

If I wanted a polymorphic `bar()`, the IDE had to generate the same enormous structure again as `BAR_ENTRY_000` through `BAR_ENTRY_511`. The experiment proved an operation could be extended, but every operation still owned a private registry whose capacity was stamped into a generated header.

I got tired of that quickly. Could every polymorphic function share one registry? Receiver type alone was no longer sufficient: `Greet.greet(Person *)` and `Container.get(Person *)` might both dispatch on `Person`, but they are not the same operation. The selection key needed to carry the function’s identity at the type level.

That is where selector tags came from. Each trait method gets a distinct one-byte tag struct type. Dispatch no longer asks only “what is the receiver?” It asks for the pair:

```txt
(selector tag, concrete receiver type)
```

Now unrelated polymorphic functions can occupy the same extensible registry without colliding. An implementation contributes another typed pair when its block includes `trait.h`; no IDE macro has to pre-generate a private universe for that function.

## One API, two dispatch engines

If you don't know what C11 `_Generic` is, <a href="https://en.cppreference.com/c/language/generic" target="_blank" rel="noopener noreferrer">check it out here</a>.

Allan’s version ends in `_Generic`, which means C11. I like writing C99 with Clang. “Works in C, provided your C is new enough” was not going to do it for me.

The registry and octal counter do not care about `_Generic`. Only the final type check does. I needed another way to build that check in C99, so `trait.h` resolves a compilation mode before it defines `call()`:

```c
#if defined(TRAIT_MODE) && ___TRAIT_MODE_SEL == 1
  #define ___TRAIT_C23 0
  #define ___TRAIT_CE 1
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ < 201112L && \
      (defined(__GNUC__) || defined(__clang__))
  #define ___TRAIT_C23 0
  #define ___TRAIT_CE 1
#else
  #define ___TRAIT_CE 0
#endif
```

`TRAIT_MODE=c99` can force the older backend for testing; otherwise a pre-C11 `__STDC_VERSION__` under a GNU-compatible compiler selects it automatically. The same static trait code builds in GNU C99 mode with GCC, Clang, and the Intel C Compiler (yes, I literally checked to make sure Intel's compiler accepts this code).

C99 has no `_Generic`, but GCC-compatible compilers expose enough pieces to construct the same decision another way. The resolved mode pivots the entire type-selection backend:

- **GNU99:** build a nested <a href="https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html" target="_blank" rel="noopener noreferrer">`__builtin_choose_expr` tree</a>, using `__builtin_types_compatible_p` to compare each selector/receiver pair;
- **GNU11 and C23:** build a `_Generic` association list over those same generated pair types.

Conceptually, the C99 route grows into this:

```c
__builtin_choose_expr(
  __builtin_types_compatible_p(__typeof__(key), slot_0),
  function_0,
  __builtin_choose_expr(
    __builtin_types_compatible_p(__typeof__(key), slot_1),
    function_1,
    ERROR_trait_not_implemented
  )
)
```

The newer route grows sideways instead:

```c
_Generic(
  key,
  slot_0: function_0,
  slot_1: function_1,
  default: ERROR_trait_not_implemented
)
```

Either way, the caller writes `call(Trait.method, &value)` and gets a direct call. The mode check is not decorative compatibility glue. It changes the program the preprocessor builds.

## Now add associated types

Associated types make this stranger. A `Container` for `Box` should return an `int`; the same trait for `Person` should return a `const char *`.

```c
#define Trait Container
#define ContainerSignature(Self) \
  required(Self, Container_Item, get) \
  required(Self, void, set, Container_Item)
#include <trait.h>
```

`Container_Item` is supplied while each implementation is expanded.

This works because `ContainerSignature` is a macro definition, not an eagerly checked C declaration. The preprocessor stores its replacement tokens and expands them later when `trait.h` generates the implementation and call machinery. `Container_Item` can name a completely nonexistent type when I define the signature; the token only has to exist **as a type** at the moment that particular expansion becomes C.

That is easy to arrange with ordinary preprocessor scope:

```c
#define Container_Item int
/* expand ContainerSignature for this implementation */
#undef Container_Item

#define Container_Item const char *
/* expand the same signature again */
#undef Container_Item
```

So the signature behaves like a delayed template over a temporarily bound type symbol. There is no associated-type object or runtime metadata. The name only needs to become a valid C type long enough to generate one registration.

Rust has a name for the same relationship:

```rust
trait Container {
    type Item;

    fn get(&self) -> Self::Item;
    fn set(&mut self, value: Self::Item);
}

struct NumberBox {
    value: i32,
}

impl Container for NumberBox {
    type Item = i32;

    fn get(&self) -> Self::Item {
        self.value
    }

    fn set(&mut self, value: Self::Item) {
        self.value = value;
    }
}

impl Container for Person {
    type Item = &'static str;

    fn get(&self) -> Self::Item {
        self.name
    }

    fn set(&mut self, value: Self::Item) {
        self.name = value;
    }
}
```

`Container_Item` is the preprocessor-bound counterpart to `Self::Item`: much less principled, considerably more cursed, but resolved separately for each implementation.

```c
typedef struct {
  int value;
} Box;

#define For Box
#define Impl Container
#define Container_Item int
int def(get) {
  return self->value;
}
void def(set, int value) {
  self->value = value;
}
#include <trait.h>
#undef Container_Item
```

The implementation for `Person` repeats the registration with a different associated type:

```c
typedef struct {
  const char *name;
} Person;

#define For Person
#define Impl Container
#define Container_Item const char *
const char *def(get) {
  return self->name;
}
void def(set, const char *name) {
  self->name = name;
}
#include <trait.h>
#undef Container_Item
```

The compiler now knows the return type selected for each receiver. We can stop here and write normal `call(Container.get, &box)`.

## Opting into dynamic dispatch

Static dispatch is the default, but sometimes the whole point is to erase the concrete type. Adding `#define Dynamic` when declaring a trait generates its vtable and a non-owning `DynTrait` fat pointer:

```c
#include <stdio.h>
#include <trait.h>

#define Dynamic
#define Trait Greet
#define GreetSignature(Self) \
  required(Self, void, greet)
#include <trait.h>

typedef struct {
  const char *name;
} Person;

#define For Person
#define Impl Greet
void def(greet) {
  printf("Hello, %s!\n", self->name);
}
#include <trait.h>

int main(void) {
  Person person = { .name = "World" };

  call(Greet.greet, &person);

  DynGreet erased = dyn(Greet, &person);
  call(Greet.greet, &erased);
}
```

Both calls use the same selector. The first sees `Person *` and resolves directly to `Person_Greet_greet`. The second sees `DynGreet *`, selects the generated dynamic wrapper, and follows the stored vtable entry.

At the dynamic boundary, the Rust comparison becomes almost literal. `DynGreet` is a non-owning fat pointer in the same sense as Rust’s <a href="https://doc.rust-lang.org/reference/types/trait-object.html" target="_blank" rel="noopener noreferrer">`&dyn Greet` trait object</a>: one pointer identifies the concrete object and another identifies the trait vtable.

```rust
let erased: &dyn Greet = &person;
erased.greet();
```

`dyn(Greet, &person)` is the C spelling of that conversion. It does not allocate, copy, or take ownership of `person`. Unlike Rust, C has no borrow checker proving the relationship, so the caller must ensure the concrete object outlives every `DynGreet` that points at it.

I pay for indirection only after writing `dyn()`. Concrete values keep the static path.

Now for the bad taste.

## The extra-sleek layer

The following header adds `var`, `let`, and `$`. It is not required by `c-trait`; it is merely what happens when a clean result encourages poor impulse control.

<details>
<summary>prelude.h</summary>


```c
// clang-format off
#ifndef PRELUDE_H
#define PRELUDE_H

#include <trait.h>

#if defined(__cplusplus)
  #if __cplusplus >= 201103L
    #define var auto
    #define let const auto
  #else
    #error "prelude.h requires C++11 or later"
  #endif
#elif (defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L) || defined(__TINYC__)
  #define var auto
  #define let const auto
#else
  #define var __auto_type
  #define let const __auto_type
#endif

#define $(method, obj, ...) call(method, obj, ##__VA_ARGS__)

#endif
```

</details>

With that small offense in place, the whole example reads like this:

```c
// clang-format off
#include <stdio.h>
#include "./prelude.h"

typedef struct {
  int value;
} Box;

typedef struct {
  const char *name;
} Person;

#define Trait Container
#define ContainerSignature(Self) \
  required(Self, Container_Item, get) \
  required(Self, void, set, Container_Item)
#include <trait.h>

#define For Box
#define Impl Container
#define Container_Item int
int def(get) {
  return self->value;
}
void def(set, int value) {
  self->value = value;
}
#include <trait.h>
#undef Container_Item

#define For Person
#define Impl Container
#define Container_Item const char *
const char *def(get) {
  return self->name;
}
void def(set, const char *name) {
  self->name = name;
}
#include <trait.h>
#undef Container_Item

int main(void) {
  Box box = {0};
  Person person = {0};

  $(Container.set, &box, 15);
  $(Container.set, &person, "Jason");

  let result1 = $(Container.get, &box);
  let result2 = $(Container.get, &person);

  printf("%d\n", result1);
  printf("%s\n", result2);
}
```

The two `let` declarations infer different types:

```txt
result1: const int
result2: const char * const
```

The dispatch is still static. `$` only aliases `call()`, and `let` only selects C23 `auto` or the older GCC/Clang `__auto_type` extension. The compiler resolves `Container.get` from the concrete receiver and propagates the implementation’s return type into the declaration.

The surface syntax is almost suspiciously pleasant.

## The cursed half

The C preprocessor cannot append an association to an existing `_Generic`, iterate over a trait’s methods, or maintain normal mutable state. `c-trait` responds by making `trait.h` include itself under different state macros.

Each pass does one part of the job:

1. emit method declarations and selector types;
2. generate implementation wrappers;
3. register selector/concrete-type pairs;
4. advance a global preprocessor counter;
5. re-enter the header for the next method.

Every method receives a unique one-byte tag struct type. Static dispatch constructs a function-pointer type from the method selector and receiver types, then asks either `_Generic` or the `__builtin_choose_expr` tree to match it against the registered pairs. The chosen expression is a generated wrapper function; the compiler discards the others.

The mechanism is open-world, at least within one translation unit. An implementation claims new dispatch slots when its block includes `trait.h`. I do not return to a central type list, add another branch to a master switch, or regenerate a dispatch table. The implementations grow the registry themselves.

The registry is indexed by a seven-digit octal counter. Seven octal digits provide 2,097,152 dispatch slots before we run out of road.

No one should instantiate two million methods. I just wanted enough road that the counter would never be the first thing to break.

## Where it bends

There are catches:

- static registrations are translation-unit local;
- compile time grows linearly with the dispatch registry;
- a trait tops out at 15 methods;
- GNU99 and GNU11 modes rely on compiler extensions;
- diagnostics eventually reveal the machinery underneath.

C23 is the cleanest target because it standardizes `typeof`, empty variadic invocations, and the other pieces older modes borrow from GCC, Clang, and Intel C. But GNU99 is a real supported route, not a reduced demo: it generates the choose-expression tree and preserves the same static-dispatch API. The optional `$` spelling above is less respectable.

The dynamic path still matters. Heterogeneous collections and boundaries that hide the concrete type need erasure. That is what `dyn()` is for. Static traits cover the much larger set of calls where the concrete type never went anywhere.

## Blessed, cursed, useful

The machinery is cursed. Fine.

Outside the header there is not much to remember. Define operations. Implement them. Call through the selector. The data stays boring, which is exactly what I wanted, and a concrete call stays a concrete call.

I bent the preprocessor until it produced that result. Whether this was wise is a separate question.

Go read the header, run the examples, or break it yourself in the <a href="https://github.com/elias-michaias/c-trait" target="_blank" rel="noopener noreferrer">`c-trait` repository</a>.
