Generate functions during comptime

Is it possible to generate functions during comptime? The following code attempts to generate the getter functions of a struct (not that I need getters, just an example). It has problem at @field(S, getter_name).


const User = struct {
    name:   []const u8,
    age:    u32,

    comptime {
        generateGetters(@This());
    }
};

fn generateGetters(comptime S: type) void {
    const info = @typeInfo(S).@"struct";

    inline for (info.fields) |field| {
        // Generate getter
        const getter_name = "get_" ++ field.name;
        fn @field(S, getter_name)(self: *const S) field.type {
            return @field(self, field_name);
        }
    }
}

That’s not valid syntax. Generating functions at comptime (beyond anytype monomorphization) is not possible. You’d have to do it through code generations, such as via the build system. Also worth noting that getters/setters isn’t really idiomatic Zig, though maybe you were just using that as an example.

Here’s a good read: https://matklad.github.io/2025/04/19/things-zig-comptime-wont-do.html

6 Likes

As already noted, this is not possible with Zig, at least using the way that you are might be used to doing it other languages.

However, there are projects that accomplish the same end result using the build system, which can be used to generate source code, import it into a module, and then use it within your project.

Some notable examples that your might find interesting:

Each of these doesn’t come with actual code, you configure them in your build script, generate, then @import and use them normally.

However, none of these will add functions scoped under existing types like in your example.

What I noticed is that it’s not possible to do stuff like

const SomeType = struct {
    pub fn someMethod(self: SomeType) void { /* ... */ }
    pub fn someOtherMethod(self: SomeType) void { /* ... */ }
    // a lot of other methods
};
const SomeOtherType = struct {
    inner_value: SomeType,
    // It doesn't work to automatically make all methods of
    // `SomeType` available here.
};

I guess that’s the reason why we often see things like:

(edit: This is not idiomatic, see response below.)

const SomeOtherType = struct {
    inner_value: SomeType,
    pub fn some(self: SomeOtherType) SomeType {
        return inner_value;
    }
};

Such that you can write:

some_other_value.some().someMethod();

Methods like std.fs.File.writer feel similar to that pattern: A File will not act like a Writer but you must obtain a writer through a method.

(Not sure if I observed that right.)

Anyway, I’m amazed how Zig manages providing a lot of reflection and enabling generic programming nonetheless (e.g. allowing implementation of the print functions) in Zig.

The minimalism somehow reminds me of (typical coding in) Lua, even though Lua only works at runtime in this matter, while Zig can perform monomorphization at compile time.

1 Like
const SomeOtherType = struct {
    inner_value: SomeType,
    pub fn some(self: SomeOtherType) SomeType {
        return inner_value;
    }
};

This use of getters/setters is not idiomatic Zig, which would simply access the field directly. This is not a pattern you should use in Zig unless it is to fulfill some interface requirement.

The distinction should be made, using your example, that the use of writer() functions are not just to be a getters, but to wrap the field into an interface.

2 Likes

Thanks for all the information. It’s really helpful.

I tried to challenge this (to some extent) with a bit of syntax gymnastics :man_lifting_weights::

const std = @import("std");

pub fn dispatch(comptime name: []const u8, args: anytype) void {
    _ = args;
    comptime var is_foo: bool = undefined;
    comptime var is_test: bool = undefined;
    comptime var suffix: i32 = undefined;
    comptime success: {
        is_foo = std.mem.startsWith(u8, name, "foo");
        if (is_foo) {
            suffix = std.fmt.parseInt(i32, name[3..], 0) catch {
                @compileError("Tried to call unknown function");
            };
            if (suffix < 0 or suffix >= 10000) {
                @compileError("Tried to call unknown function");
            }
            break :success;
        }
        is_test = std.mem.startsWith(u8, name, "test");
        if (is_test) {
            suffix = std.fmt.parseInt(i32, name[4..], 0) catch {
                @compileError("Tried to call unknown function");
            };
            if (suffix < 0 or suffix >= 100) {
                @compileError("Tried to call unknown function");
            }
            break :success;
        }
        @compileError("Tried to call unknown function");
    }
    if (is_foo) {
        std.debug.print(
            "Called foo function with suffix {}.\n",
            .{suffix},
        );
    } else if (is_test) {
        std.debug.print(
            "Called testing function with suffix {}.\n",
            .{suffix},
        );
    } else unreachable;
}

pub fn main() void {
    dispatch("foo12", .{});
    dispatch("test12", .{});
    dispatch("foo3745", .{});
    //dispatch("test3745", .{}); // try to uncomment me
}

This generates effectively 10100 possible “functions” (not really functions, but things you can call with dispatch("some_function_name", .{})):

  • foo0 to foo9999
  • test0 to test99

(plus equivalents with leading zeros)

Output:

Called foo function with suffix 12.
Called testing function with suffix 12.
Called foo function with suffix 3745.

Uncommenting the last line then results, expectedly, in:

comptimefunc.zig:25:17: error: Tried to call unknown function
                @compileError("Tried to call unknown function");
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
referenced by:
    main: comptimefunc.zig:48:13
    callMain [inlined]: /home/jbe/lib/zig/std/start.zig:666:22
    callMainWithArgs [inlined]: /home/jbe/lib/zig/std/start.zig:635:20
    main: /home/jbe/lib/zig/std/start.zig:650:28
    1 reference(s) hidden; use '-freference-trace=5' to see all references

Thus reporting a compile-time error, demonstrating that dispatching is indeed done at compile time, right?

Not sure if this is useful for anything.

That is still just monomorphization :wink:

$ nm ./dispatch | rg dispatch
000000010009ecd0 t _mono.dispatch__anon_19578
000000010009ece4 t _mono.dispatch__anon_19583
000000010009ecf8 t _mono.dispatch__anon_19588
1 Like

I have been curious if it is at all planned to add actual support for this. And if not, why not?

I know generating structs at comptime is possible, to my naive mind it seems arbitrary to not support comptime function generation.

Well, yeah, but…

… it doesn’t involve anytype at least. So I guess it’s notable that this form of comp-time dispatching doesn’t need anytype, i.e. the dispatching at compile time isn’t done using types, but using an identifier (the “name” of that “function-like-thing”).

(Sorry I don’t have better terminiology.)

Sure including anytype in that statement was imprecise, but monomorphization is what happens here because of the comptime parameter

I think in Rust this is what’s called Const generics there.

But what I like about Zig is that there is no distinction between whether we distinguish by type or []const u8 here, as types are “just” values (as far as I understand). So the same feature/syntax serves monomorphization based on types and monomorphization based on values.

Maybe there are some more differences between Rust and Zig here. For example, restricting a const param would be done through a where clause in Rust, I think, while in Zig it’s simply done in the function body using “normal” Zig syntax:

            if (suffix < 0 or suffix >= 10000) {
                @compileError("Tried to call unknown function");
            }

Taking your example (acknowledging that it is in Zig getters are not idiomatic, just following that example here), and using comptime arguments, it would be possible to do:

const std = @import("std");

const User = struct {
    name: []const u8,
    age: u32,
    pub fn get(
        self: User,
        comptime field: []const u8,
    ) @FieldType(User, field) {
        std.debug.print("Reading field \"{s}\".\n", .{field});
        return @field(self, field);
    }
};

pub fn main() void {
    const user = User{ .name = "Alice", .age = 34 };
    std.debug.print("User {s}, age {}.\n", .{
        user.get("name"),
        user.get("age"),
    });
}

Output:

Reading field "name".
Reading field "age".
User Alice, age 34.

I don’t know, but I wonder if it’s really needed, given that Zig seems to be quite powerful already as is. Maybe you have a specific use-case in mind?

I’d say that the key point here is when the parameters to the function are known. Types are values only at compiletime. They don’t exist after compiletime. Because your [] const u8 is compiletime known it gets the same ‘monomorphization’ treatment.

1 Like

Oh, I didn’t realize that. Interesting!

I tried:

const std = @import("std");

pub fn foo(t: type) void {
    std.debug.print("{}", .{t != void});
}

pub fn main() void {
    for (0..10) |_| {
        const b = std.crypto.random.float(f64) < 0.4;
        foo(if (b) i32 else void);
    }
}

And indeed I get:

type.zig:10:13: error: value with comptime-only type 'type' depends on runtime control flow
        foo(if (b) i32 else void);
            ^~~~~~~~~~~~~~~~~~~~
type.zig:8:10: note: runtime control flow here
    for (0..10) |_| {
         ^~~~~
type.zig:10:13: note: types are not available at runtime
referenced by:
    callMain [inlined]: /home/jbe/lib/zig/std/start.zig:672:22
    callMainWithArgs [inlined]: /home/jbe/lib/zig/std/start.zig:641:20
    main: /home/jbe/lib/zig/std/start.zig:656:28
    1 reference(s) hidden; use '-freference-trace=4' to see all references

Thanks to have pointed that out.

One use case I ran into was making a machine learning runtime that create a struct for a model containing its weights. The issue arose when automatically generating the “run” function for said struct, which had to invoke a series of predetermined helper functions.

I don’t recall right now how I got around this, I think there is a way as you say!

Question: Does that mean that comptime before some t: type argument is superfluous, and just a matter of good coding style? (I.e. it doesn’t make a difference whether you write comptime before a type argument?)

2 Likes

yes, comptime is not necessary there. It used to be required I believe, but with types that requirement was relaxed.

1 Like