Typeclasses for WGSL
Intro
I was looking at the problem of bringing generic programming to the WGSL programming language.
There are multiple competing approaches for doing so, from adopting ML-modules to typeclasses. I never really understood what they do and how they work. Until I started working it out from the ground up, after reading up on the topic and talking to someone smarter than me.
Turns out that most systems are expressions of the same underlying ideas. To show what I mean, let’s start with something that WGSL already has and go from there.
@const @must_use fn floor(e: T) -> T
where
S is AbstractFloat, f32, or f16
T is S or vecN<S>
and
@const @must_use fn array<T, N>(e1 : T, ..., eN : T) -> array<T, N>
where
T is concrete and constructible
So WGSL has generics with generic types.
But they are still extremely limited. You cannot express the predeclared functions without
- some way of doing constraints
- some way of choosing the implementation
This is interesting (I hope)
And now let’s do that in a few different styles!
Overloading (sidequest)
fn floor(e: T) -> T
could, if all we had was overloading, instead be written as
fn floor(e: AbstractFloat) -> AbstractFloat
fn floor(e: f32) -> f32
// ... //
fn floor(e: vec4<f16>) -> vec4<f16>
The array function however cannot be expressed. It actually needs a builtin constructible constraint. constructible is a bit like the Send and Sync traits in Rust. It automatically applies to structs where all fields are constructible. So overloading is not expressive enough.
Passing along a constraint
If we start at the very basics, then
fn floor(e: T) -> T
can be extended with a “constraint” by just giving it another parameter. Yes, that changes the signature, but bear with me.
fn floor(e: T, e_constraint: Floorer<T>) -> T
But how would we now implement the body? It has nontrivial semantics, where it looks at e_constraint and picks the correct implementation among many.
fn floor(e: T, e_constraint: Floorer<T>) -> T {
// how do we express this?
return e_constraint.floor_impl(e);
}
If we had objects (yes, object oriented!), then we’d get
fn floor(e: T, e_constraint: Floorer<T>) -> T {
// this looks up the method table for e_constraint
// and dispatches the appropriate floor_impl
return e_constraint.floor_impl(e);
}
It does need a method table, because otherwise we’d always be dispatching the same function regardless of what Floorer<T> we’re passing in.
If we had traits, then we would write code akin to
fn floor<T: Floorer>(e: T) -> T {
return T::floor_impl(e_constraint, e);
}
where the trait solver then selects the applicable impl Floorer for our T. So it can be imagined as desugaring to
fn floor<T>(e: T) -> T {
let e_constraint = ... get the Floorer for T ...;
return e_constraint::floor_impl(e);
}
Here it should be noted that traits have complicated machinery for getting the applicable Floorer implementation.
If we had modules, we would make e_constraint be a module. And from there, we look at what floor_impl the module has.
fn floor(e: T, e_constraint: Floorer<T>) -> T {
return e_constraint::floor_impl(e);
}
But this all is just hiding a more fundamental idea. The idea that the original typeclasses paper called a “dictionary” of methods. Aka a method table in object oriented languages. Or a module with functions in ML languages! Or a concrete implementation of a typeclass/trait.
The idea really is just that a concrete Floorer<f32> is shaped like
{
floor_impl: |e: f32| -> { return e - frac(e); }
}
It is a dictionary, mapping from function names to implementations.
But that seems inefficient? Passing around dictionaries at runtime? In a shading language?
No problem, let’s move that to compile time. Let’s express it with something else that WGSL already has. A const generic!
fn floor<T, const e_constraint: Floorer<T>>(e: T) -> T
So this clearly still maps to the module and the typeclass styles. But it also maps to something new.
Passing a const generic struct
Most languages, including all the cool ones, treat functions as first class citizens. That means you can pass them around, just like data.
So let’s imagine if we had that in WGSL
struct Floorer {
floor_impl: fn(f32) -> f32
}
fn floor<T, const e_constraint: Floorer<T>>(e: T) -> T {
return e_constraint.floor_impl(e);
}
At runtime, this would become an integer ( function pointer) and we’d always generate a switch statement when calling it. Best we can do, given the limitations of graphics APIs.
But at compile time? At compile time, we know exactly which function it is. So we can expand it out into ordinary function calls.
Here’s what I mean. We take this piece of WGSL code
fn main() {
const my_float_floorer = Floorer(
|a: f32| -> f32 { return ... }
)
let b: f32 = floor<f32, my_float_floorer>(random_value);
}
and during const eval, we successively expanded it. First it becomes a function pointer.
fn floor_impl_0001(a: f32) -> f32 { return ... }
fn main() {
const my_float_floorer = Floorer(floor_impl_0001);
let b: f32 = floor<f32, my_float_floorer>(random_value);
}
Then we need to do const eval for the floor<f32, my_float_floorer> function. This is done by monomorphizing. (Alternatively, it can be done by inlining and then const eval.)
So let’s run monomorphization. We start with
fn floor<T = f32, const e_constraint = Floorer(floor_impl_0001)>(e: T) -> T {
return e_constraint.floor_impl(e);
}
We have to pick a unique name for it, so that we can refer to the result. And we evaluate the generics.
fn floor_UNIQUE_NAME_0001(e: f32) -> f32 {
return Floorer(floor_impl_0001).floor_impl(e);
}
After that, we still need to finish evaluating Floorer(floor_impl_0001).floor_impl.
All that gives us our final code
fn floor_impl_0001(a: f32) -> f32 { return ... }
fn main() {
let b: f32 = floor_UNIQUE_NAME_0001(random_value);
}
fn floor_UNIQUE_NAME_0001(e: f32) -> f32 {
return floor_impl_0001(e);
}
And what machinery did we need? Const eval and first class functions. That’s neat. WGSL has the first half and it seems very reasonable to demand first class functions.
This idea isn’t novel at all, Rust’s traits can be elaborated to that style. And Gleam is doing something relatively close to this (I think).
This is useful!
This abstraction by itself is already very useful. It gives users a way to do generic programming, even without us having more advanced features. I think this is already worth shipping. It does not let you model or extend the WGSL standard library, but it does allow you to build generic libraries.
Associated types
In the dictionary passing style, an associated type turns into an actual Type type. You end up treating data types as first class citizens. See also https://nadrieril.github.io/blog/2026/03/20/dictionary-passing-style.html#the-tricky-part-associated-types-and-equality.
Getting back to floor
So we showed that this variant of floor can be expressed quite elegantly in a lot of ways. And they all have the same fundamental dictionary of functions idea.
fn floor<T, const e_constraint: Floorer<T>>(e: T) -> T
But WGSL’s floor doesn’t require me to pass along a const e_constraint! Let’s make it implicit. We’ll use the using keyword for this, since here we are roughly doing what Scala does.
fn floor<T, using e_constraint: Floorer<T>>(e: T) -> T {
return e_constraint.floor_impl(e);
}
So now we need a mechanism to obtain the e_constraint.
Which, once again, has been done before us.
- Object oriented languages would say that
eis an object and you can look up the method table. - Typeclass languages, such as Rust, would say that
eis a type and types carry around dictionaries of methods. - And in ML-style languages, one would use Modular Implicits to express this. Before modular implicits, there wasn’t really a way of expressing that.
And thus, we finally get to the point where
fn floor<T, using e_constraint: Floorer<T>>(e: T) -> T {
return e_constraint.floor_impl(e);
}
can have nicely defined semantics and actually be implemented in WGSL with minor extensions!
Except now you need to figure out what your rules for implicits are. Global coherence? Local coherence? What about specialization?
But that’s a topic for another day