The article by Noel Welsh about Zig’s comptime sits at an interesting intersection: it is technically about parametricity, but the practical questions it raises apply to anyone writing or reviewing generic Zig code. The theoretical framing is worth understanding on its own terms, and so are the specific consequences Welsh glosses over.
The Relational Interpretation of Types
Parametricity is not just a property of type systems; it is a way of reading types as behavioral specifications. The formal machinery comes from John Reynolds’s 1983 paper “Types, Abstraction and Parametric Polymorphism” and Philip Wadler’s “Theorems for Free!” (1989). The central idea: every type can be interpreted as a relation, and parametric functions must preserve those relations.
In concrete terms, a Haskell function reverse :: [a] -> [a] must commute with any mapping over a. If you have a function f :: A -> B, then:
map f . reverse = reverse . map f
This is not a test you run; it is a theorem you get for free from the type. No inspection of reverse’s implementation required. The reason is that reverse cannot inspect the elements of its list argument at all. It has no knowledge of what a is. Given that constraint, any rearrangement it performs on the list must be independent of the elements’ values, which means the rearrangement commutes with any element transformation.
GHC uses exactly this theorem. Stream fusion and rules like map f . map g = map (f . g) are valid rewrite rules precisely because parametricity guarantees they hold for any function with those types. Without parametricity, the compiler cannot apply them safely.
Where Zig Departs
Zig’s comptime gives functions full access to the structure of their type arguments via @typeInfo. The result is a tagged union describing the type: an .Int with its signedness and bit width, a .Struct with its field names and types, a .Union with its variants. A generic function can switch on this and produce entirely different behavior for different types.
This is not a corner case or an abuse of the feature; it is the intended use. The standard library’s std.fmt uses it to implement format strings. Serialization libraries use it to walk struct fields. A mathematical library might use it to select between integer and floating-point algorithms. The entire generics story in Zig is built on this inspection capability.
The consequence is that the type signature fn foo(comptime T: type, x: T) T tells you almost nothing about what foo does. It accepts a value of some type and returns a value of that type. Whether it returns x unchanged, sorts it (if T is a slice), increments it (if T is an integer), or does nothing (if T is a struct) is fully determined by the implementation, not the signature. The type is a syntactic shape, not a behavioral contract.
The Rust Specialization Parallel
The Rust community is actively wrestling with this exact trade-off. RFC 1210, proposing specialization for Rust’s trait system, has been open since 2015. It would allow a more-specific trait implementation to override a general one, enabling type-specific behavior in what appears to be generic code. The RFC has remained unstable for over a decade, and one significant reason is that specialization breaks parametricity and specifically threatens the soundness of even the restricted min_specialization subset that landed in nightly.
The problem: parametricity is load-bearing for the borrow checker’s reasoning in some cases. When impl<T> Foo for T can be silently overridden by impl Foo for String, the guarantees the compiler derives from the generic implementation may not hold for the specialized one. This is not a hypothetical; unsoundness bugs related to specialization have been filed repeatedly against nightly Rust.
Zig sidesteps this problem by not having the guarantees in the first place. There is no parametricity to violate. The type inspection is explicit, syntactically visible in the function body, and does not depend on separate impl blocks that might override each other silently. The cost is the loss of reasoning power; the benefit is a coherent, tractable mechanism without the soundness traps that have stalled Rust’s specialization for years.
Constraint Declarations Without Syntax
One specific gap that the absence of parametricity creates: there is no standard way in Zig to declare what a type parameter must provide. Rust trait bounds (T: Iterator, T: Serialize) appear in function signatures. Haskell typeclass constraints (Ord a, Show a) appear in function signatures. A reader can see immediately what a generic function expects from its type argument.
In Zig, a function that requires T to have a serialize method, or to support +, or to have a specific field named id, encodes that requirement nowhere visible. The requirement is discovered at instantiation time, when the compiler tries to use the missing capability and fails. The error points to the function body, not to any declaration of what was needed.
The Zig standard library mitigates this in several ways. Some modules use @compileError to generate explicit error messages when a type lacks a required method:
comptime {
if (!@hasDecl(T, "readByte")) {
@compileError("Reader type must implement readByte");
}
}
This works when the library author remembers to write it, but it scatters the interface description through the implementation rather than surfacing it in the function signature. It is documentation by convention, not enforcement by the type system.
The anytype parameter is the most extreme version of this. A function accepting anytype makes no promises about what it will accept; every caller is effectively a fresh instantiation of the function. This is ergonomic for small utilities and tests, but makes generic interfaces between library components harder to read and audit at scale.
When This Trade-Off Matters
For a specific class of systems programming tasks, Zig’s approach is the right call. A serialization library that needs to handle integers, floats, structs, and enums differently is better written with direct @typeInfo inspection than with workarounds that pretend the code is parametric. A memory allocator that needs to align differently for different types, a network protocol parser that dispatches on message type structure, a SIMD library that uses different intrinsics for different numeric widths: these all benefit from being able to inspect types directly without the overhead of trait objects or virtual dispatch.
The free theorems that parametricity provides are most valuable when you are building software where equational reasoning matters: functional pipelines, parser combinators, generic container libraries where map, filter, and fold compose in predictable ways. These are not the primary use cases for Zig.
Where the trade-off becomes more visible is in large Zig codebases with many contributors. Parametricity’s value as a documentation mechanism compounds with scale. When a generic function is known to be parametric, you do not need to audit it for type-specific behavior; the type system enforces the absence of such behavior. When it might not be parametric, every function is a potential source of type-specific surprises. The discipline that prevents this must come from code review and convention, not from the compiler.
Welsh’s analysis describes comptime as “bonkers” from the perspective of type theory, and the characterization is fair in the sense that it is genuinely unusual for a modern language to offer this much compile-time type inspection without maintaining any parametricity properties. The more complete framing is that Zig is making an explicit bet: systems programmers benefit more from expressive compile-time metaprogramming than from the reasoning guarantees that parametricity provides, and the ergonomic gains from a single unified comptime mechanism outweigh the loss of formal behavioral contracts. That bet is coherent, and the Rust specialization story is useful evidence that maintaining parametricity while adding type-specific dispatch is a harder problem than it looks.