Even More Tagged Union Subsets with Comptime
Even More Tagged Union Subsets with Comptime
May 18, 2026
One of the cooler things you can do with Zig's comptime is create what<br>Mitchell Hashimoto calls "tagged union subsets." Hashimoto shows<br>how he can take an existing tagged union, one representing all possible<br>keyboard shortcut actions in Ghostty, and use comptime<br>to derive more specific unions representing just those actions affecting a<br>particular terminal window or just those actions affecting the entire<br>application.
Having these more specific union types available means that he can write<br>functions accepting only terminal-specific actions or only application-wide<br>actions. These functions look something like this, where ScopedAction(.app)<br>is a call to a function that runs at comptime and returns the more specific<br>union type:
1// This function handles application-wide keyboard shortcuts.<br>2pub fn performAppAction(action: ScopedAction(.app)) void {<br>3 switch (action) {<br>4 .quit => ...,<br>5 .close_all_windows => ...,<br>6 .open_config => ...,<br>7 .reload_config => ...,<br>8 }<br>9}<br>10<br>11// This function handles shortcuts scoped to a terminal window.<br>12pub fn performTerminalAction(action: ScopedAction(.terminal)) void {<br>13 switch (action) {<br>14 .new_window => ...,<br>15 .close_window => ...,<br>16 .scroll_lines => ...,<br>17 }<br>18}<br>This pattern is useful mainly because Zig has exhaustive switching. Given a function like performAppAction() above, Zig will yell at you<br>(helpfully) if you add a new application-wide action but forget to add a<br>matching case to the switch. If performAppAction() instead took an<br>action of type Action, then Zig's yelling would lose specificity; Zig<br>would yell at you if you were to add a terminal-specific action without a<br>matching case. That's much less helpful, since in the context of<br>performAppAction() we don't care about terminal-specific actions.
A Similar Problem with Trees
I recently ran into a situation that seemed to call for using tagged union<br>subsets. I ended up with a solution heavily inspired by Hashimoto's but<br>different in a few ways that I think are interesting.
I am working on a parser for MyST, a new Markdown format. Markdown parsers typically read an input<br>document and produce HTML directly, but MyST has a spec that<br>defines an AST for parsed MyST documents. Much<br>of the code in my parser is concerned with constructing and traversing this<br>AST.
The MyST AST has many different types of nodes. To represent this, I use a<br>large tagged union of node types.
Some nodes (headings, inline emphasis, MyST directives) are<br>allowed to have children while others (inline code, images) are not. While<br>working on the parser, I have often found myself writing functions that<br>transform the AST using a recursive operation on nodes in the tree. Functions<br>in this vein want to do one thing with nodes of a particular type, but, for all<br>other nodes, provided they have children, want to just recurse through to all<br>descendants.
My first attempt at implementing these functions looked something like the<br>following. In this example, I'm traversing the tree looking for<br>myst_directive nodes to hand off to the code that implements MyST directives.
1fn transformDirectives(alloc: Allocator, node: *ast.Node) !*ast.Node {<br>2 switch (node.*) {<br>3 // The nodes we're looking for. We pass the node to a function that<br>4 // will implement the directive by adding children to the node.<br>5 .myst_directive => return try transformBuiltinDirective(alloc, node),<br>6 // Nodes with children. We need to recurse to look for more directives.<br>7 // `inline` is a fancy Zig keyword that allows the below switch<br>8 // prong to be polymorphic across all our node types with children.<br>9 inline .block,<br>10 .heading,<br>11 .paragraph,<br>12 .emphasis,<br>13 .strong,<br>14 ...,<br>15 => |node_payload| {<br>16 for (0..node_payload.children.len) |i| {<br>17 node_payload.children[i] = try transformDirectives(<br>18 alloc,<br>19 node_payload.children[i],<br>20 );<br>21 }<br>22 return node;<br>23 },<br>24 // Leaf nodes, nothing to do.<br>25 .text,<br>26 .image,<br>27 .code,<br>28 .inline_code,<br>29 ...,<br>30 => return node,<br>31 }<br>32}<br>This switch has three cases. In order to correctly partition all the various<br>node types into the three cases, I need to list them all out. This is verbose<br>(especially because in real life there are many more types than shown above).<br>Worse, since I have many functions like this one, every time I add a new node<br>type to the AST, I need to update dozens of switch statements across my<br>codebase to make sure every node type is correctly identified as either a node<br>with children or a leaf node.
We might be tempted to make this better by eliding the leaf node types with<br>an else case:
1fn transformDirectives(alloc: Allocator, node: *ast.Node) !*ast.Node {<br>2 switch (node.*) {<br>3 // The nodes we're looking for. We pass the node to a function that<br>4 // will implement the directive by adding children to the node.<br>5 .myst_directive => return try transformBuiltinDirective(alloc, node),<br>6 // Nodes with children....