Functional Programmers need to take a look at Zig.

I’ve been tinkering around with Zig to explore what’s possible with comptime. Whenever I evaluate a new language I use three axes:

  1. How well can I express my ideas in this language. Or in other words, how easy is it for me to express the domain of the program. This is a test on how much noise is applied to the ideas I want to express in the program. Noise is anything that must be written for the program to function that is not relevant to the domain. For example, the canonical example of noise is the need to do manual memory management. We must allocate memory for the program to run, but this is orthogonal to the program’s domain; its an implementation detail.
  2. What facilities does the language provide me to create correct-by-construction systems and how easily can I program the type-system. This is essentially a test on how well I can program the language itself or how well I can create a deep embedding.
  3. What is the mean-time to a surprise. In the study of vacuum systems (think outer space) there is a concept called the mean-free path length or just mean-free path. The mean-free path of a vacuum system is the average distance a particle can travel in the system without experiencing a collision, it is essentially a metric of how good the vacuum is. When I apply this concept to programming languages I think of it as “How much code can I write before my implementation differs from my understanding of the system I am implementing”. This is why I frame this metric as a “surprise”; its “how many lines of code can I write until I experience a surprise”. And a surprise is a delta between what I think I’ve implemented and what I’ve actually implemented.

Enter Zig. I’m interested in Zig for a few reasons. First, I suspect that comptime is a simpler and more flexible system to achieve a lot of the type-system programming I’ve seen in the Haskell-verse and I’ve done enough Haskell (over 10 years) that programming the type system is now a hard requirement for me to take any language seriously.

Second, I am desperately trying to avoid writing a functional systems language. This is probably a blog post in its own right but the programming language industry has not grokked the meaning of monads. Monads are not some kind of obscure math-y thing that only the big brains think are necessary. No, instead monads are a fundamental abstract algebraic description of imperative programming as a computational context. They allow a programming language to not have a built-in notion of time (among other things). So if I want an imperative programming language I can implement MonadCont (the continuation monad), if I want a logic programming language I can implement LogicT (a monad that has non-deterministic semantics and backtracking). Not having a built-in notion of time means that my language is de-facto more expressive, allows users to mold the language to their needs, and improves the optimization ceiling compilers for that language can achieve.

So how does this connect with systems programming? Well, I’ve been radicalized. I’ve learned enough performance-oriented programming to be dissatisfied with the common functional languages (Haskell, OCaml, Common Lisp/Clojure, Scheme) because each of these languages are predicated on the existence of garbage collection and heaps. I think we are at the tail end of a large scale experiment with garbage collection. We can now look back on the last 30 years and conclude that garbage collection does communicate immense value by reducing noise, but the tradeoff is that the one ends up with a forest of pointers into the heap and that will always create a performance ceiling for the program and language implementation.

To exacerbate matters, I think there is a cognitive risk to garbage collection. Garbage collection makes it too easy to not think about or care about the underlying machine and runtime system. This has created a generation of developers who never gained or have lost the knowledge of how programs actually execute on a computational machine. Or to use less flowery language, just look at the era of software that garbage collectors have ushered in. Programs are bloated, slow, and wasteful compared to the literal super-computers that are running them. Surely we can do better.

Furthermore, I think the value proposition of garbage collectors has changed. The first garbage collector was innovated in LISP in 1957, but once they gained prominence in 1995 due to Java they proliferated, and for good reason. However, the machines of 2026 are much different than the machines of 1995 (but our languages aren’t ). Since 1995, compute on a CPU has grown something like 10,000 times faster while memory access timing has lagged. That was not the case in 1995. In 1995 these were roughly comparable. So we are in a situation where we are using languages designed for the machines of yesteryear that do not consider the machines of today. As an industry we (largely) have stopped innovating on new languages.

I once saw a talk by Steven Diehl that asked Where the next Programming Language will come from? that beautifully described the sad state of things. His main point is that the incentives for programming language innovation are at best misaligned and at worst non-existent. He states that we can assume there are three groups of people that are capable of innovation: Academics, Industry, and Hobbyists. But academics have no incentive to do the real-world engineering required to make a viable programming language, and any academics who decide to try are committing career suicide. Industry cannot fund any long-term projects (due to its culture of shareholder-value maximization) and are tied into sticky network effects. Hobbyists (generally) don’t have the time nor the economic means to make something real; which takes decades of full time work to accomplish. And so we are stuck with a local maxima.

Okay now back to Zig. I’m bullish on Zig because Zig (and its BDFL Andrew Kelley) are innovating and have the courage to innovate. Here are some innovations. Zig discourages the forest of pointers approach and encourages better manual memory management through Arenas and Allocators. This means that users have much more control over the memory management of their programs. This is just one reason why stuff written in Zig is so damn fast; Zig programs tend to exploit the machines of today better than the machines of yesteryear. Zig 0.16 just released and reworked the IO system to an interface design, here is the example from the release notes:

const std = @import("std");
const Io = std.Io;

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;

    const args = try init.minimal.args.toSlice(init.arena.allocator());

    const host_name: Io.net.HostName = try .init(args[1]);

    var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
    defer http_client.deinit();
...
}

You know what I see when I look at this code? I see http_client as existing in a Reader monad that contains an allocator and an IO interface. This is exactly how the IO monad (and for that matter IO#) works in Haskell. The fact that the Zig people came up with this independantly speaks not just to the universal nature of monads (and the algebraic structures of programming languages) but also tells me that they are absolutely on the right track. Kudos to that team! I even mentioned as much on the orange website which set off a great discussion for those that are interested.

My last example is comptime. Recall that I want to be able to create correct-by-construction programs. This means that I need nominal typing. Well a Haskell-like newtype. In Zig is just a singleton struct:

struct PlayerHealth {
  health: u32
}

Nice! Okay what about a sum type. Easy that’s just a union:

  const std = @import("std");

  // the venerable Maybe, even though Zig has builtin syntax for this
  fn Maybe(comptime T: type) type {
    return union(enum) {
        value: T,
        nothing,

        const Self = @This();

        pub fn just(the_val: T) Self   { return .{ .value = the_val }; }
        pub fn nothing() Self          { return .nothing; }

        // this is fmap and exhibits a Haskell-like case expression
        // map :: Maybe(T) -> Maybe(B)
        pub fn map(self: Self, comptime B: type, f: fn (T) B) Maybe(B) {
            return switch (self) {
                .value => |v| .{ .value = f(v) }, // the Just case
                .nothing => .nothing,
            };
        }
    };
}

Very nice! What about typeclasses? Again comptime and structs are the way:

    const std = @import("std");

    // This is: class Eq where ...
    fn Eq(comptime T: type) type {
      // Observe that we can not only program at compile time
      // but also write our own error messages _and_ the language encourages
      // us to do this
      if (!@hasDecl(T, "eql")) // this says that T must have an `eql` method
          @compileError(@typeName(T) ++ " must implement eql(T, T) bool");

      return struct {
          pub fn eql(a: T, b: T) bool { return T.eql(a, b); }
          pub fn neq(a: T, b: T) bool { return !T.eql(a, b); }
      };
    }

    // A user defined type
    const Point = struct {
      x: i32,
      y: i32,

      // instance Eq Point where ...
      pub fn eql(a: Point, b: Point) bool {
          return a.x == b.x and a.y == b.y;
      }
  };

  // now use it
  pub fn main() void {
    const EqPoint = Eq(Point);
    const a = Point{ .x = 1, .y = 2 };
    const b = Point{ .x = 1, .y = 2 };
    const c = Point{ .x = 3, .y = 4 };

    std.debug.print("a == c: {}\n", .{EqPoint.eql(a, c)});  // false
    std.debug.print("a == b: {}\n", .{EqPoint.eql(a, b)});  // true
}

You’ll notice that this is a but clunky; we have to instantiate a Eq(Point) and then call EqPoint.eql. EqPoint is essentially the typeclass dictionary that Haskell creates behind the scenes, and so in Zig we have to manually pass that dictionary around. The good part is that this comports with Zig’s “no spooky action at a distance” philosophy, typeclass dictionary passing is spooky-action at a distance. But we can make this more ergonomic by just deriving Eq on our Point type:

const std = @import("std");

fn EqClass(comptime T: type) type {
    if (!@hasDecl(T, "eqlImpl")) // now we verify there is an instance implementation
        @compileError(@typeName(T) ++ " must implement eqlImpl(T, T) bool");

    return struct {
        // and these functions just wrap the implementation provided by T
        pub fn eql(a: T, b: T) bool { return T.eqlImpl(a, b); }
        pub fn neq(a: T, b: T) bool { return !T.eqlImpl(a, b); }
    };
}

const Point = struct {
    x: i32,
    y: i32,

    // the actual implementation, this is the body of (==) in the instance
    pub fn eqlImpl(a: Point, b: Point) bool {
        return a.x == b.x and a.y == b.y;
    }

    // derive Eq: EqClass reads eqlImpl above so we don't create a cycle
    pub const Eq = EqClass(@This());
};

pub fn main() void {
    const a = Point{ .x = 1, .y = 2 };
    const b = Point{ .x = 1, .y = 2 };
    const c = Point{ .x = 3, .y = 4 };

    std.debug.print("a == c: {}\n", .{Point.Eq.eql(a, c)});  // false
    std.debug.print("a == b: {}\n", .{Point.Eq.eql(a, b)});  // true
    std.debug.print("a != c: {}\n", .{Point.Eq.neq(a, c)});  // true
}

Not bad. Its less ergonomic than Haskell for sure but I think that’s a good thing. In fact, I look at this, squint really hard and see something like an ML module system hiding behind the scenes. I could go on. In fact, in Zig you can use structs to create closures, to curry, and therefore write higher-ordered functions all without a garbage collector (if that interests you check out the standard libraries sort function). But I’ll save that for another post.

Needless to say I’m bullish on Zig. I think Zig is doing so much right. Zig has what I need and want to express my domain and ideas succinctly (this was 1. in my original criteria). Zig’s comptime has very good support for programming the type system and creating correct-by-construction programs. I still have to experiment more with it, but I think it is atleast in the ball park of Haskell and easier to program at that (this was 2.). Finally, Zig’s “no spooky action at a distance” means I have not once been surprised about the semantics in my experimentation. I’m sure there will be edge cases but I can confidentally say that Zig has a greater mean free path length than languages that its often positioned against: Rust, C++ (for sure). I love Haskell and its been my go to language for the last 10-13 years but god damn I see a lot of Zig in my future.


Written by doyougnu on 2026-04-29
Blog Publications Github About