A better anytype?

I was reading GitHub issues as a way to unwind, and I came across issue #17198 in the Zig repository. I read through the entire discussion, and after some reflection on the type system (pun intended), I thought of an idea that might serve as a reasonable middle ground. However, since I’m still relatively new to Zig, I hesitated to comment directly on the issue for fear of proposing something naive or irrelevant. At the same time, I’d like to discuss my idea on anytype and gather feedback.

I have mixed feelings about anytype. I don’t think it’s inherently great or terrible. I understand its purpose. While anytype can sometimes be frustrating to work with, the ability to jump to the definition and inspect the code often makes it a manageable annoyance. Based on the issue’s discussion, the primary complaint seems to be that anytype shifts the responsibility of understanding the expected type to the user of the code. Although this can be mitigated with compile-time checks in userland, it does require extra work from the implementer. The result is that users can be left with something quite opaque.

This led me to an idea. Since the maintainers prefer to avoid low-quality proposals (which I understand and respect), I won’t submit this formally. I’m content with the current state of things, but I still wanted to share the thought, and who knows maybe I’m totally wrong and this can’t work at all, or maybe someone can make an actual good proposal based on that idea.

Basically instead of allowing anytype to be used directly in function definitions, what if anytype could only be accessed through a new builtin, like @anytype(fn (type) bool)? This function would take another function that accepts a type and returns a bool.

fn isNumeric(comptime T: type) bool {
    return switch (@typeInfo(T)) {
        .Float, .Int => true,
        else => @compileError("The type provided " ++ @typeName(T) ++ " isn't numeric"),
    };
}

fn add(value: @anytype(isNumeric)) @TypeOf(value) {
    return value + value;
}

pub fn main() !void {
    const i: i32 = 4;
    std.debug.print("{d}", .{add(i)});
}

I realize this explanation might be a bit unclear, but the core idea is that in order to use anytype in an API, the developer would be required to provide a function that “validates” or “processes” the type. This would enforce a layer of explicit type handling, potentially reducing the ambiguity that anytype can introduce.

Does this make sense as a possible approach? I’d love to hear thoughts on whether this could address some of the concerns raised in the issue.

3 Likes

1669. Sadly, it was rejected.

3 Likes

I personally think that it is also a strength that anytype doesn’t require type constraints.
For example in your case you are over-constraining the add function.
You want a type that has the + operator, but you constraining it to runtime integers and runtime floats. But @Vector types as well as comptime_int and comptime_float also support +.

I think in this case it actually hurts readability to have this complex validation function.

Furthermore I think the error message provided by the compiler is usually better and more actionable (it tells me directly which property or function is missing) than the user defined error message which often ends up being rather vague (what is a numeric type? Why is comptime_int not considered a numeric type?):

error: invalid operands to binary expression: 'bool' and 'bool'
    return value + value;
           ~~~~~~^~~~~~~
referenced by:
    main: test.zig:20:32
-----------------------------------------------------------
error: The type provided bool isnt numeric
        else => @compileError("The type provided " ++ @typeName(@TypeOf(value)) ++ " isn't numeric"),
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

referenced by:
    main: test.zig:20:32

I also think that anytype is a bit of a tooling issue. Even with your proposal it’s still hard to communicate to the user what properties a type must fulfill. I think either way we would need the language server to analyze the function and tells us which properties the input must fulfill or which functions it should have.

1 Like

Yeah I don’t have much hope, and like I’ve said I’m ok with the current state, but I think my idea is a bit different in the sense that it removes anytype or to put it better it kinda puts it behind a paywall if that make sense, because has mentioned in the different issues, they don’t want to encourage overly generic code, which is totally understandable, and they don’t want to implement a trait/interface because they want to keep the language simple, but that still doesn’t change that anytype exist and that although it can be ok if it’s well documented, or evident from the context what you need to provide, I can remember a few times where I was a bit confused for a while. The suggestion of replacing anytype by a builtin that evaluates the type passed to the function through a mandatory checking function, seems like a good middle ground, it adds more friction for the writer, so you may consider another better approach, and if it was still your preferred approach, it gives something a bit more readable from the user perspective, all the while remaining fairly simple, but I’m sure there is some reason why I’m completely wrong,

Yes I agree the example I provided is not very good, and like I said, I’m really not sure it’s necessary to change anytype, It’s annoying sometimes, but it’s generally easy to understand what’s needed, I just wanted to know if the idea of adding a bit more friction for the implementer could yield a clearer path for the user. Thanks for sharing, do you think this can be improved ? or do you think the current status quo is just fine as is ?

Take a look at these proposals: 6615 (closed) and 9260 (open).

1 Like

I think anytype is fine as it is, and cannot be improved without changing other parts of the language and making it more complicated.
Maybe the introduction of some kind of compile time interfacese/traits would help, but this would fundamentally change the language and it will produce other problems elsewhere (e.g. consider 2 different libraries which both have a different declaration of the same interface).

4 Likes

I get it and you are probably right, I just think that replacing anytype with a builtin that takes a named function of type fn (comptime T : type) bool could improve the readability from the user perspective, while adding just a tiny bit more friction from the writing perspective. Obviously it’s hard to really gauge the scope of the change I’m suggesting. But in practice you could also do this if you really want the classic anytype behavior.

fn isAny(comptime _ : type) bool {
    return true;
}

fn add(value: @anytype(isAny)) @TypeOf(value) {
    return value + value;
}

Obviously I can see how this looks kind of ugly, but at least the noise does convey some potential for extra information.

fn hasCustomFormatFn(comptime T: type) bool {
    return switch (@typeInfo(T)) {
        .@"struct" => if (@hasDecl(T, "format")) true else false,
        else => false,
    };
}

fn prettyPrint(value: @anytype(hasCustomFormatFn)) void {
    std.log.debug("{}", .{value});
}

const Foo = struct {
    msg: []const u8,

    pub fn init(msg: []const u8) Foo {
        return .{
            .msg = msg,
        };
    }

    pub fn format(
        self: @This(),
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        _ = fmt;
        _ = options;
        try writer.print("{s}", .{self.msg});
    }
};

pub fn main() !void {
    const foo: Foo = .init("hi");
    prettyPrint(foo);
}

I’m probably wrong, but it seems like a happy middle ground where you can still get the “normal” anytype behavior, but with this approach, once you already have a function to check your anytype you might aswell put some more comptime validation logic inside to make it nicer to use. A bit like when you replace make with build.zig, your not far away from actually starting to use zig itself. My examples aren’t that great but at least from reading the different issues, it seems like they are opened to the idea of replacing anytype with infer T which would at least allow to name the type to convey some more information.

2 Likes

I’ve also come to some sort of a conclusion that anytype should not be used in every place where “normal” Zig type names are used, 'cause anytype is not a name of a type within Zig type system (including comptime type type), it (anytype) just does not fit it (type system) very well imho.

1 Like

Yeah it’s definitely the “type” that feels the most out of place, maybe its a necessary evil, and there isn’t a better solution that can express it without bringing to many problems but in the zig zen there is “avoiding local maximums”. I think we ought to discuss alternatives even if it takes time to find a solution that can improve the situation I think it’s worth trying. But it’s definitely a hard problem to tackle.

1 Like

direct proof

const std = @import("std");

pub fn main() void {

    const a: anytype = 1;
    std.debug.print("{}\n", .{a});
}
a.zig:6:14: error: expected type expression, found 'anytype'
    const a: anytype = 1;

One possible solution might be removing anytype completely (using explicit T: type in function args will always(?..) do), but I am not a person to measure the severity that could follow such “revolution”.

1 Like

aha, why not use just any for type place holder in function signature.
with a notion that this quantifier can only be used for a function argument types and nowhere else… really a hard problem… :frowning:

2 Likes

I’ve just thought - would infer (in place of anytype) be a better name?..
Since it’s a verb, it directly reflects the fact that anytype is actually a ‘directive’ to the compiler and a verb cannot be a name of a real type.

A better anytype?

No anytype at all! :slight_smile:

When a type is omitted, compiler is able to infer it in several situations.
Well, then just

fn fun(a1, a2: <a-type>)

would give same effect as anytype (or whatever name).

2 Likes

This would definitely remove some semantic confusion.

If I understood the issue on github, I think that infer T is suppose to improve the readability by allowing you to name the anytype. so

instead for having

std.io.bufferedWriter(underlying_stream: anytype) BufferedWriter(4096, @TypeOf(underlying_stream));

you could have

std.io.bufferedWriter(underlying_stream: infer AnyWriter) BufferedWriter(4096, @TypeOf(underlying_stream));

Something along those line, of course this is my understanding, and the example isn’t the best one, but sometimes you have multiple anytype parameters plus a Context type. and It’s quite confusing not because of the generic nature, but because it’s not obvious what is roughly expected.

Yeah, I got it. anytype is too “wide”, so to say - there may be not only ‘ducks’, but also ‘nails’, ‘hammers’, ‘guns’, ‘roses’ and so on and it’s unclear (without digging deeper) what is needed in a particular case and your wish is to put some restriction by applying some checker function or so. I’ve talked about other aspect - to me anytype as a keyword (for the status quo) seems to be needless. You can write const x = 1.2, why can’t you write fn f(x, ...)? Semantic is the same - type is omitted => infer it (and produce specialized version of a function in the second case).

1 Like

Meanwhile, write docs/comments about what type you expect.

1 Like

documentation driven development (DDD, 3D if you please :slight_smile: ) - it is really new development driver paradigm (as opposed to ‘test driven development’ for ex. ) :slight_smile: i’m kidding, do not pay much attention.

1 Like

Yes I mean at this point I’m fairly confident that we won’t get any form of type constraints, in the language, but I would gladly settle for the proposal of implementing infer T where T can be named that would already be a bit easier. I’m still not a fan of how easy it is to write vs how demanding it can be to read, but at least that would make it more convenient. I think your solution doesn’t solve the initial issue of readability, I’m all for more inference, but if it starts to make code unclear, I think it ought to be avoided.

3 Likes

You know, you can spell “T” as something more meaningful, if the type you want is something specific. As it happens std containers can hold anything, so you generally see “T”, which is shorter than “Anything”.

1 Like