Yeah, if want to have runtime state associated with a function you will need to pass that state around alongside the function (or use a static variable, but I usually try to avoid that).
Your version seems good. Another version similar to what you originally posted and inspired by the implementation of std.sort.insertion is:
I think an important question here is whether the set of possible “callbacks” is closed or open, meaning: “are all the possible kinds of callbacks known at compile time and they only differ by runtime data?”. If that is the case I would ditch the callbacks and use a tagged union instead:
If the set is open and you need to choose at runtime, you can define an interface that uses type erasure like std.mem.Allocator instead of the tagged union.
If the choice can be made at comptime you could use a comptime enum (possibly associated with a runtime payload/tagged union) like here: Tagged union with comptime tag - #2 by Sze
I think the other answers are also valid and useful, I just prefer union if it is enough to do the job. I enjoy that it can be used to basically limit the scope of what needs to be considered, in my example you simply see it takes a *Compute you look at compute and can see all the cases, which also makes it easier to evaluate whether countUntil works correctly for all cases. With open solutions you don’t get that. So I guess my 2 cents are, don’t use too generic solutions, staying specific has its benefits.