What's the right way to do an enum subset?

I’m trying to create an enum SupportedOS such that it’s a subset of std.Target.Os.Tag.

I need this because the library I’m writing needs to have behavior that’s dependent on target OS. The enum subset helps me write test exhaustively without referencing the original Os.Tag enum which covers a lot more operating systems than the library can support atm.

Wondering if I’m over-engineering here or perhaps there’s a language feature I missed.

pub const SupportedOS = enum {
    windows,
    linux,
    freebsd,
    macos,
    other,
};

comptime {
    for (std.enums.values(SupportedOS)) |f| {
        if (!@hasField(std.Target.Os.Tag, @tagName(f)))
            @compileError("SupportedOS contains field not present in Os.Tag: " ++ @tagName(f));
    }
}

/// Returns `SupportedOS` enumeration based on provided `tag`. If the tag
/// signifies an unsupported operating system, this method will return `other`.
pub fn getSupportedOS(tag: std.Target.Os.Tag) SupportedOS {
    inline for (std.enums.values(SupportedOS)) |f| {
        if (std.meta.eql(@field(std.Target.Os.Tag, @tagName(f)), tag))
            return @field(SupportedOS, @tagName(f));
    }
    return .other;
}

Hello @Adil-Iqbal,

I love your solution, it is elegant. But as you already said it is overengineered. It is the proper solution if you expect to have switch statements on getSupportedOS and you want to handle all OS cases. If you expect to write if (getSupportedOS(builtin.os.tag) == .windows) { then a better solution might be to use helper functions like:

const builtin = @import("builtin");

pub fn isWindows() bool {
     return builtin.os.tag == .windows;
}

Welcome to ziggit :slight_smile:

Another option would be to define or generate the entries in SupportedOS to have the same value like their entry in std.Target.Os.Tag, then your getSupportedOS function would just become this:

return std.enums.fromInt(SupportedOS, @intFromEnum(tag)) orelse .other;
5 Likes

There’s another method described here: Tagged Union Subsets with Comptime in Zig – Mitchell Hashimoto

Basically, make a fn isSupported(os: std.Target.Os.Tag) bool then build a custom SupportedOs type at comptime, along with helpers to convert back-and-forth between the two types.

It seems a bit nicer to me, though its hard to say why. For one, your ‘SupportedOS contains field not present in Os.Tag’ error message wouldn’t be necessary anymore, because it’d be impossible to get into that situation. I also like his use of null instead of .other.

2 Likes

Why define a new type when what you have is a list? Just then create an array of std.Target.Os.Tag:

pub const supported_os = [_]std.Target.Os.Tag{
    .windows,
    .linux,
    .freebsd,
    .macos,
};

I think the enum is useful for switching on it in different functions, especially if you use exhaustive switching and later add a new one.

2 Likes

If you expect to write if (getSupportedOS(builtin.os.tag) == .windows) { then a better solution might be to use helper functions like:

The problem with this is that you can’t switch on isWindows. You also probably want to make this function inline and return type comptime_int to make sure it doesn’t evaluate runtime. Or always remember to call it in a comptime context.

Another option would be to define or generate the entries in SupportedOS to have the same value like their entry in std.Target.Os.Tag, then your getSupportedOS function would just become this

You can also avoid the ABI compatiblity with original enum, by doing
return std.meta.stringToEnum(SupportedOS, @tagName(tag)) orelse .other; this is basically same as the original getSupportedOS function.

I don’t think I would describe it as ABI compatibility, while it uses the value of the enum, it is only an implementation detail (so it is more an Application Binary Implementation Detail, then it is an Interface, because the user still only uses the name with some implementation specific value) and if you generate it / rely on the super enum’s values it is automatically in sync (so I don’t think there is a big difference between matching names only or values too).

I think the code of stringToEnum is a bit more complex instead of matching simple values to eachother, in general I think matching enum values in useful ways is a bit underrated.

That said, both approaches have their benefits depending on use case.


Thinking a bit more about it, I would change the subset to still have the entries with matching values but then use a non-exhaustive enum for the subset, that way you can get rid of .other and instead use _ for other, if you define the subset enum with the same backing int you can just do @enumFromInt(@intFromEnum(tag)), additionally you can even do the same to convert it back to the super-set, so the whole thing just becomes some integer with 2 two different, but related sets of names.

1 Like

I am not sure if this is a good idea, but using a non-exhaustive enum, you can do something like this:

const SupportedOs = enum(@typeInfo(std.Target.Os.Tag).@"enum".tag_type) {
    windows = @intFromEnum(std.Target.Os.Tag.windows),
    linux = @intFromEnum(std.Target.Os.Tag.linux),
    freebsd = @intFromEnum(std.Target.Os.Tag.freebsd),
    macos = @intFromEnum(std.Target.Os.Tag.macos),
    _,
};

pub fn main() !void {
    const os: SupportedOs = .linux;
    if (os == .linux) {
        std.debug.print("Linux is good\n", .{});
    }

    const os2: SupportedOs = @enumFromInt(@intFromEnum(std.Target.Os.Tag.cuda));
    switch (os2) {
        .windows => std.debug.print("windows\n", .{}),
        else => std.debug.print("linux, freebsd or macos", .{}),
        _ => std.debug.print("anything else in std.Target.Os.Tag", .{}),
    }
}
1 Like

Wow. The resulting API is really pretty and extensible. It’s even more over-engineering though. I might just have to bite the bullet. I can’t risk adding support for a platform but then forgetting to implement a feature.

pub fn performAppAction(action: ScopedAction(.app)) void {
  switch (action) {
    .quit => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,
  }
}

I think Sze has it right. On launch, I’m just supported the os’s that represent the largest marketshare. The end goal of the library is to support all the platform that zig itself supports (which is a tall order).

I expect the library to grow and all features should work on all supported operating systems. Finally, adding a supported operating system should be easy to do (ideally a one line change – and compiler jumps into action telling me what I’ve missed).

I like it. I think you could refine it even further to a comptime function that takes your base enum type and a list of enum literals to create the same thing with less boilerplate.