hi present-day seb here: i wrote most of this blog post like 2 years ago (september 2024), but i never published it because i didn't really have an active blog at the time. i just found it in a random file and i figured i'd publish it. enjoy :)
In Tagged Union Subsets with Comptime in Zig, Mitchell Hashimoto demonstrates how Zig's comptime feature can be used to create subsets of tagged unions. This, among other things, allows the benefits of compile-time exhaustivity checking to be retained even when switching on a subset.
So, how does Hare hold up? Like with Zig, tagged unions are first-class types in Hare. It's also very common for a tagged union to contain nested tagged unions:
type a = (int | void);
type b = (int | str);
type c = (a | b);
Nested tagged unions aren't squished by default, meaning that the members are stored as separate tagged unions. The benefit to this approach is that the int members of a and b can be easily distinguished, while keeping the tag representation of all tagged unions compatible. However, you can also squish tagged unions into a single tagged union, as the Zig code does:
type c = (...a | ...b); // equivalent to (int | void | str)
Let's write the desired logic from the aforementioned blog post, but in Hare. To summarize, we want an action tagged union, which contains subsets for different "scopes" of actions ("app" or "terminal"). The blog post uses comptime reflection to construct a new tagged union containing only the desired subset. Here's what it looks like in Zig:
pub const Action = union(enum) {
quit: void,
new_window: void,
close_window: void,
close_all_windows: void,
open_config: void,
reload_config: void,
scroll_lines: i16,
};
pub const Scope = enum { app, terminal };
pub fn scope(action: Action) Scope {
return switch (action) {
.quit, .close_all_windows, .open_config, .reload_config => .app,
.new_window, .close_window, .scroll_lines => .terminal,
};
}
/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn ScopedAction(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// Find all fields that are scoped to s
var i: usize = 0;
var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
fields[i] = field;
i += 1;
}
}
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &.{},
} });
}
pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {
switch (self) {
inline else => |v, tag| {
// Use comptime to prune out invalid actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);
},
}
}
(present-day seb here: the above code doesn't compile in the latest version of zig, because @Type was removed in 0.16.0. the code could be rewritten to use @Union instead. that really doesn't matter at all for the point i'm making here though.)
Here's what the same code looks like in Hare:
export type quit = void;
export type close_all_windows = void;
export type open_config = void;
export type reload_config = void;
export type app_action = (quit | close_all_windows | open_config | reload_config);
export type new_window = void;
export type close_window = void;
export type scroll_lines = i16;
export type terminal_action = (new_window | close_window | scroll_lines);
export type action = (...app_action | ...terminal_action);
That's it! No custom functions or compile-time evaluation necessary. Here's how it's used:
export fn perform_action(act: action) void = {
match (act) {
case let act: app_action =>
perform_app_action(act);
case let act: terminal_action =>
perform_terminal_action(act);
};
};
app_action and terminal_action are squished into action, but you can still match on them to obtain an app_action or terminal_action. At runtime, this code behaves nearly identically to the equivalent Zig code.
If action has large members and you want to avoid a copy, you could match on a pointer instead:
export fn perform_action(act: *action) void = {
match (act) {
case let act: *app_action =>
perform_app_action(act);
case let act: *terminal_action =>
perform_terminal_action(act);
};
};
Because of the way tagged unions are represented in memory, the same memory can be reinterpreted as a subtype tagged union, with no additional runtime cost (besides the cost of the match itself).
I think this does a good job of highlighting some differences between Zig's philosophy and Hare's philosophy. Zig doesn't provide these facilities directly, but its comptime and reflection systems are powerful enough that you can implement them on your own. Mitchell's blog post did an excellent job of demonstrating how powerful this is. Hare, in contrast, doesn't have type reflection, and its compile-time evaluation is very limited. However, its builtin tagged union facilities cover most use-cases, making them very easy to use straight away.