switch expression
A typed build choice replaces a preprocessor branch.
Zig difference: The compile-time-known switch produces a module namespace.
We already have an App with a create/run/destroy lifetime. But when we open src/main.zig, we notice it is doing two different jobs:
Let’s separate them. main.zig will become a tiny switchboard, and the actual Ghostty startup code will live in main_ghostty.zig:
zig build → build.zig chooses src/main.zig → src/main.zig reads build_config.zig → .ghostty selects main_ghostty.zig → main_ghostty.main creates AppThis is not a menu the user sees. Zig picks the route while compiling, so the finished program goes straight to the selected main.
chapter-02-entry-routing$ zig build run
[entry] ghostty
[main] process started
[app] created
[app] running
[app] destroyed
[main] process exitingCaptured from commit 4a3538b with Zig 0.16.0. The first line makes the selected entry module visible.
Only one line is new:
[entry] ghosttyThe rest is Chapter 01’s App lifetime.
src/main.zig executable root and compile-time routersrc/build_config.zig typed choice referenced by the routersrc/main_ghostty.zig selected application startupsrc/App.zig functions called by startupbuild.zig why compilation begins at src/main.zigWe won’t read these alphabetically. We’ll do what you probably do in a new C project: start at main, follow the names and calls we meet, and look at the build file after we understand the program.
We will account for every meaningful line. Blank lines only separate ideas.
src/main.zigOpen the program’s root file first. Its job is to choose which main function this executable should use.
The executable root reduced to the only reconstructed entrypoint.
const build_config = @import("build_config.zig");
/// See build_config.ExeEntrypoint for why the executable routes this way.
const entrypoint = switch (build_config.exe_entrypoint) {
.ghostty => @import("main_ghostty.zig"),
};
/// The build-selected process entrypoint.
pub const main = entrypoint.main;
test {
_ = entrypoint;
}const build_config = @import("build_config.zig");@import returns the imported file’s namespace. The local name build_config gives us access to:
build_config.ExeEntrypointbuild_config.exe_entrypointNothing is being loaded while the program runs. This happens during compilation.
We just found a new name, build_config, so let’s follow it and see what exe_entrypoint actually contains.
src/build_config.zigThis tiny file turns the build’s choice into a normal Zig enum. If you know C enums, this will feel familiar.
Static one-entry replacement for Ghostty's generated build configuration.
pub const ExeEntrypoint = enum {
ghostty,
};
/// The build-selected executable entrypoint.
pub const exe_entrypoint: ExeEntrypoint = .ghostty;pub const ExeEntrypoint = enum { ghostty,};This declares a public enum type named ExeEntrypoint.
enum creates a closed set of tags.ghostty is the only tag in this checkpoint.}; closes and terminates the type declaration.C mental model:
typedef enum { EXE_ENTRYPOINT_GHOSTTY,} ExeEntrypoint;The Zig tag belongs to its type. It is not an unchecked global integer macro.
/// The build-selected executable entrypoint./// is a documentation comment for the declaration directly below it.
pub const exe_entrypoint: ExeEntrypoint = .ghostty;Read it from left to right:
pub: importing modules may access it;const: the declaration cannot be rebound;exe_entrypoint: its name;: ExeEntrypoint: its explicit type;= .ghostty: its value.The expected type lets Zig shorten:
ExeEntrypoint.ghosttyto:
.ghosttysrc/main.zigGood—we now know the value is .ghostty. Let’s put that fact into the switch we saw in main.zig.
Start with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceA typed build choice replaces a preprocessor branch.
Zig difference: The compile-time-known switch produces a module namespace.
Text version: The typed build value selects the
main_ghostty.zignamespace at compile time. The root module then aliases that namespace’smainfunction directly.
const entrypoint = switch (build_config.exe_entrypoint) {Unlike a C switch that often just runs statements, this Zig switch produces a value. Here the value is the imported main_ghostty.zig namespace.
The input is a compile-time-known constant, so this does not add a runtime branch.
.ghostty => @import("main_ghostty.zig"),.ghostty is the enum case to match.=> separates the case from its result.@import(...) produces the selected namespace.};The brace closes the switch. The semicolon terminates the entrypoint declaration.
mainpub const main = entrypoint.main;This does not create a wrapper like:
pub fn main() !void { return entrypoint.main();}This line is basically an alias. It does not create a little wrapper function that calls another function, so there is no extra runtime hop.
pub controls Zig namespace visibility. It does not by itself export a C ABI symbol.
test { _ = entrypoint;}Zig has built-in test declarations. This one has no display name.
Zig analyzes declarations lazily. Referencing entrypoint makes the selected module reachable during zig build test.
_ is the discard target. It says we intentionally use the value only to make it reachable.
C mental model: (void)entrypoint; explains the discard, but not Zig’s lazy semantic analysis.
src/main_ghostty.zigThe router handed us entrypoint.main, so let’s open that function next. This is where the program actually starts creating the application.
Chapter 01 startup moved intact behind the selected entry module.
const std = @import("std");
const App = @import("App.zig");
/// The application entrypoint selected by src/main.zig.
pub fn main() !void {
std.debug.print("[entry] ghostty\n", .{});
std.debug.print("[main] process started\n", .{});
{
const app = try App.create(std.heap.page_allocator);
defer app.destroy();
app.run();
}
std.debug.print("[main] process exiting\n", .{});
}const std = @import("std");const App = @import("App.zig");The first line imports the standard library. The second imports the App.zig file container.
Because App.zig has fields, its file container is also the App struct type. App therefore acts as both:
a type+a namespace containing App functions/// The application entrypoint selected by src/main.zig.pub fn main() !void {The comment documents the function.
!void means:
success → no valuefailure → an inferred errorThe possible allocation failure from App.create becomes part of main’s return type.
C mental model: int main(void) is close in shape, but C’s integer status is only a convention.
std.debug.print("[entry] ghostty\n", .{});std.debug.print("[main] process started\n", .{});std.debug.print writes formatted bytes to standard error.
\n is one line-feed byte..{} is an empty tuple of formatting arguments.There are no replacement fields in these strings, so the tuple is empty.
{This inner block controls when defer runs.
const app = try App.create(std.heap.page_allocator);Read this from right to left:
App.create for an App;try returns that error from main;*App to app.The local type is inferred.
const app fixes the pointer binding. It does not make the pointed-to App immutable.
C mental model:
App *const app = app_create(page_allocator);if (app == NULL) return 1;defer app.destroy();This schedules destruction for the end of the current inner block.
It is structured cleanup. It is not a promise that cleanup survives forced process termination.
app.run();Dot-call syntax resolves this as:
App.run(app);There is no hidden class or virtual table.
}Leaving the block runs the deferred app.destroy().
std.debug.print("[main] process exiting\n", .{});}The final trace therefore happens after App destruction. The last brace ends main. Reaching it means successful void completion.
src/App.zigNow we have three questions: what does App.create allocate, what does run do, and why do we need both deinit and destroy? Let’s follow those calls.
Chapter 01 App ownership remains unchanged while startup moves behind the router.
const App = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
/// General-purpose allocator used to own the stable App pointer.
alloc: Allocator,
pub const CreateError = Allocator.Error;
/// Allocate and initialize the process-wide application state.
pub fn create(alloc: Allocator) CreateError!*App {
const app = try alloc.create(App);
errdefer alloc.destroy(app);
app.init(alloc);
std.debug.print("[app] created\n", .{});
return app;
}
pub fn init(self: *App, alloc: Allocator) void {
self.* = .{ .alloc = alloc };
}
/// Release resources owned by App.
///
/// Chapter 01 has no resources beyond the allocation itself. Later chapters
/// will add cleanup here as App gains real state.
pub fn deinit(self: *App) void {
_ = self;
}
/// Deinitialize App and release its stable allocation.
pub fn destroy(self: *App) void {
const alloc = self.alloc;
self.deinit();
std.debug.print("[app] destroyed\n", .{});
alloc.destroy(self);
}
/// Temporarily stand in for the platform runtime's event loop.
///
/// A later chapter moves this responsibility out of App.
pub fn run(self: *App) void {
_ = self;
std.debug.print("[app] running\n", .{});
}
test "create and destroy App" {
const app = try App.create(std.testing.allocator);
app.destroy();
}App type?const App = @This();@This() returns the current file container type. Since this file declares fields, that container is the App struct.
C mental model: typedef struct App App; is close, but @This() returns the actual enclosing Zig type.
const std = @import("std");const Allocator = std.mem.Allocator;The first line imports the standard library. The second creates a short local alias for the allocator interface type.
/// General-purpose allocator used to own the stable App pointer.alloc: Allocator,The comment documents the field. Zig fields use name: Type order. The App stores its allocator because it will need the same allocator when it destroys itself.
pub const CreateError = Allocator.Error;CreateError is a public alias for the allocator’s error set. Allocation is the only fallible operation at this checkpoint.
create and init?pub fn create(alloc: Allocator) CreateError!*App {The return type parses as:
CreateError ! *AppIt contains either an allocation error or a non-null pointer to one App.
const app = try alloc.create(App);alloc.create(App) reserves correctly sized and aligned storage for one App. try propagates allocation failure and unwraps the successful pointer.
C mental model: typed malloc(sizeof(App)) plus an immediate failure check.
errdefer alloc.destroy(app);errdefer runs only if a later error exits this function. It prevents leaks during fallible initialization.
In Chapter 02 the remaining operations cannot fail, so this cleanup edge is currently dormant.
app.init(alloc);std.debug.print("[app] created\n", .{});return app;}app.init(alloc) resolves to App.init(app, alloc).return app transfers the pointer to the caller.create.The caller now owns the obligation to call destroy.
init turns raw storage into a valid Apppub fn init(self: *App, alloc: Allocator) void {self is an ordinary explicit pointer parameter. Zig has no implicit C++ this.
self.* = .{ .alloc = alloc };self.* dereferences the pointer..{ .alloc = alloc } is an App struct literal inferred from the assignment target.C mental model:
*self = (App){ .alloc = alloc };The closing brace ends init.
deinit cleans up things stored inside Apppub fn deinit(self: *App) void { _ = self;}deinit will eventually release resources stored inside App. There are none yet.
_ = self deliberately discards the unused parameter.
C mental model: (void)self;
destroy also frees the App itselfpub fn destroy(self: *App) void { const alloc = self.alloc;The function first copies out the allocator. After the App is freed, reading self.alloc would be use-after-free.
self.deinit();std.debug.print("[app] destroyed\n", .{});alloc.destroy(self);}The order is deliberate:
save allocator→ release App-owned fields→ print trace→ release App storageAfter alloc.destroy(self), self is invalid.
run is still just a placeholderpub fn run(self: *App) void { _ = self; std.debug.print("[app] running\n", .{});}This function does not use App state yet, so it discards self and prints one line.
It is temporary. Chapter 03 moves event-loop ownership out of shared App state.
test "create and destroy App" { const app = try App.create(std.testing.allocator); app.destroy();}Zig’s test runner discovers this named test.
std.testing.allocator tracks allocations. If app.destroy() were missing, the test allocator would report a leak.
build.zigWe understand the runtime path now. One question remains: how did Zig know to begin with src/main.zig?
If you come from C, think of build.zig as a typed Makefile. Reading it last makes the build API much less mysterious.
Minimal build graph for one executable and one test artifact.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "ghostty-from-scratch",
.root_module = root_module,
});
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
run.step.dependOn(b.getInstallStep());
if (b.args) |args| run.addArgs(args);
const run_step = b.step("run", "Run ghostty-from-scratch");
run_step.dependOn(&run.step);
const tests = b.addTest(.{ .root_module = root_module });
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run the Zig tests");
test_step.dependOn(&run_tests.step);
}const std = @import("std");@import("std") gives us the Zig standard-library namespace. This is not textual inclusion like a C header. No file is loaded at runtime.
pub fn build(b: *std.Build) void {pub fn declares a visible function.b: *std.Build is a pointer to Zig’s build context.void means the function returns no value.Zig’s build runner calls this function. The function creates a graph of work. It does not compile each target immediately as the line executes.
C mental model: a typed function that configures Make or CMake targets.
const target = b.standardTargetOptions(.{});const optimize = b.standardOptimizeOption(.{});Both locals use inferred types.
const means the local binding cannot be reassigned..{} is an empty options struct whose type comes from the function parameter.target stores the requested operating system and CPU target.optimize stores Debug or a release optimization mode.Zig does not require a written type for an initialized local variable.
const root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize,});This creates one module description.
.root_source_file says where compilation begins.b.path(...) makes a path relative to the project..target and .optimize copy the choices above.The important line for this chapter is:
.root_source_file = b.path("src/main.zig")That is why the compiler enters through the router.
const exe = b.addExecutable(.{ .name = "ghostty-from-scratch", .root_module = root_module,});b.installArtifact(exe);addExecutable adds an executable node to the graph. installArtifact connects it to the normal install step.
Expected installed path:
zig-out/bin/ghostty-from-scratchC mental model: define an executable target, then add it to an install target.
const run = b.addRunArtifact(exe);run.step.dependOn(b.getInstallStep());The first line creates a step that can run the executable. The second line says installation must finish first.
if (b.args) |args| run.addArgs(args);b.args is optional. It either contains no value or contains an argument slice.
if (optional_value) |payload|means: enter the body only when a value exists, and call that value payload.
C mental model: if (args != NULL), except Zig narrows the optional value to its non-null payload.
const run_step = b.step("run", "Run ghostty-from-scratch");run_step.dependOn(&run.step);This exposes:
zig build run&run.step takes the step’s address. The dependency points from the public run command to the actual executable-run step.
const tests = b.addTest(.{ .root_module = root_module });const run_tests = b.addRunArtifact(tests);const test_step = b.step("test", "Run the Zig tests");test_step.dependOn(&run_tests.step);These four lines:
test;That gives us:
zig build testThe final } closes the build function.
Here is a C-shaped mental model. It is not a direct translation because it loses Zig’s checked error union and compile-time namespace selection.
typedef struct App { Allocator alloc;} App;
App *app_create(Allocator alloc) { App *app = allocator_create(alloc, sizeof(App)); if (app == NULL) return NULL;
*app = (App){ .alloc = alloc }; fprintf(stderr, "[app] created\n"); return app;}
int ghostty_main(void) { fprintf(stderr, "[entry] ghostty\n"); fprintf(stderr, "[main] process started\n");
App *app = app_create(page_allocator); if (app == NULL) return 1;
app_run(app); app_destroy(app);
fprintf(stderr, "[main] process exiting\n"); return 0;}Compared with the C-shaped sketch, Zig makes three things explicit:
defer keeps cleanup attached to the owning scope;zig fmt --check .zig buildzig build testzig build runExpected runtime trace:
[entry] ghostty[main] process started[app] created[app] running[app] destroyed[main] process exitingIn February 2024, Ghostty commit f1227a3 moved application startup into main_ghostty.zig. main.zig became a small build-selected router. A January 2025 refactor changed the export style to the explicit declaration shape used today.
main.zig became a router and main_ghostty.zig received the existing startup code.
f1227a3Open first routerThe learner checkpoint has only .ghostty, so every existing branch has a real job.
chapter-02-entry-routingCurrent Ghostty selects the application and several generators through the same typed boundary.
6ad1fe7Open current routersrc/main.zig:1–28Read the switch and exported main. Ignore helper implementations until they have a job in the reconstruction.src/build_config.zig:1–38Read how generated build options become typed source values. Ignore renderer and platform choices for now.| File | Status | What is intentionally missing |
|---|---|---|
build.zig |
adapted | production artifacts and dependencies |
src/App.zig |
adapted | surfaces, fonts, configuration, and mailboxes |
src/build_config.zig |
adapted | generated options and additional entrypoints |
src/main.zig |
adapted | helper branches, std_options, and toolchain workarounds |
src/main_ghostty.zig |
adapted | globals, CLI actions, and a platform runtime |
commit 4a3538b7f6b569ae5749031387f22136d74fa220tag chapter-02-entry-routingmain_ghostty still calls:
app.run();That makes shared App state pretend it owns every platform’s event loop. GTK, macOS, browsers, and embedding hosts do not run the same way.
Chapter 03 introduces a headless platform runtime and one Surface-shaped session boundary. It still does not add a PTY, parser, or window.