PONY λ M2 Modula-2

Rust.CodeCompared.To/Zig

An interactive executable cheatsheet comparing Rust and Zig

Rust 1.95 Zig 0.16.0
Hello World & Imports
Hello, World
fn main() { println!("Hello, World!"); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); try stdout.writeStreamingAll(io, "Hello, World!\n"); }
Zig uses @import("std") to pull in the standard library module. There is no macro system; println! is a Rust macro, while Zig writes to stdout directly through std.Io.File.stdout() and writeStreamingAll. The !void return type means the function can return an error — try propagates any I/O error up to the runtime.
Running a program
// Compile and run (debug): // cargo run // Build release binary: // cargo build --release && ./target/release/myapp // Format code: // cargo fmt // Check for issues: // cargo clippy
// Compile and run (debug): // zig build run // Build release binary: // zig build -Doptimize=ReleaseFast && ./zig-out/bin/myapp // Build a single file: // zig build-exe hello.zig && ./hello // Format code: // zig fmt hello.zig
Zig ships as a single binary that includes the compiler, build system, package manager, and formatter — no separate Cargo or rustfmt/clippy installs. zig build run is the idiomatic way to build and execute via a build.zig file. The build system is itself written in Zig, compiled on first use.
Module imports
use std::collections::HashMap; fn main() { let mut scores: HashMap<&str, i32> = HashMap::new(); scores.insert("Alice", 42); println!("{}", scores["Alice"]); }
const std = @import("std"); // Alias a nested namespace for convenience const fmt = std.fmt; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const score: i32 = 42; try stdout.writeStreamingAll(io, try fmt.bufPrint(&buf, "{d}\n", .{score})); }
Zig has no use keyword. @import("std") returns a struct containing all standard library namespaces; assign it to const std. Alias a deep namespace with a plain const: const fmt = std.fmt. There are no use paths — access everything through the namespace hierarchy. The standard library ships with Zig; third-party packages come via the build system.
Variables & Types
let / let mut vs const / var
fn main() { let value = 42; // immutable by default let mut count = 0; // explicitly mutable count += 1; // value = 1; // compile error const LIMIT: i32 = 100; println!("{} {} {}", value, count, LIMIT); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const value = 42; // immutable — like Rust let var count: i32 = 0; // mutable — like Rust let mut count += 1; // value = 1; // compile error const limit: i32 = 100; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d} {d}\n", .{ value, count, limit })); }
In Zig, const replaces Rust's let (immutable), and var replaces let mut. There is no separate keyword for compile-time constants versus runtime immutable bindings — both use const. All local variables must be used; an unused variable is a compile error. Write _ = variable; to explicitly discard a value.
Type inference
fn main() { let number = 42; // inferred as i32 let ratio = 3.14; // inferred as f64 let label = "hello"; // inferred as &str println!("{} {} {}", number, ratio, label); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const number = 42; // comptime_int — coerces to any int at use const ratio = 3.14; // comptime_float — coerces to any float at use const label: []const u8 = "hello"; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d:.2} {s}\n", .{ number, ratio, label })); }
Zig infers types for const declarations. Integer and float literals have special compile-time types — comptime_int and comptime_float — that coerce to any compatible concrete type at the point of use. This differs from Rust, where 42 defaults to i32. Mutable var declarations usually need an explicit type because the compiler must know the storage size.
Integer types
fn main() { let a: i8 = -128; let b: u8 = 255; let c: i32 = i32::MIN; let d: u64 = u64::MAX; let e: i128 = i128::MAX; println!("{} {} {} {} {}", a, b, c, d, e); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [128]u8 = undefined; const a: i8 = -128; const b: u8 = 255; const c: i32 = std.math.minInt(i32); const d: u64 = std.math.maxInt(u64); const e: i128 = std.math.maxInt(i128); // Zig also supports arbitrary-width integers: u4, u7, u63, ... const nibble: u4 = 15; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d} {d} {d} {d} {d}\n", .{ a, b, c, d, e, nibble })); }
Both languages use explicit integer widths. Zig goes further: any bit width is valid — u4, u7, u63, even u65535. std.math.maxInt(T) and std.math.minInt(T) replace Rust's T::MAX and T::MIN. Rust allows digit separators in literals (1_000); Zig does not support digit separators.
No implicit numeric casts
fn main() { let small: i32 = 100; let big: i64 = small as i64; // explicit widening cast let ratio: f64 = small as f64; // explicit int-to-float cast println!("{} {}", big, ratio); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const small: i32 = 100; const big: i64 = small; // safe widening — implicit in Zig const ratio: f64 = @floatFromInt(small); // int-to-float requires builtin try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d:.1}\n", .{ big, ratio })); }
Zig allows implicit widening when the value is guaranteed to fit (e.g. i32 into i64), but never lossy conversions. Converting between integers and floats always requires an explicit builtin: @floatFromInt, @intFromFloat, @intCast, or @floatCast. Rust unifies all casts behind the as keyword; Zig separates safe widening from potentially-lossy casts by requiring different builtins.
No variable shadowing
fn main() { let value = 5; let value = value * 2; // shadowing: new binding, same name let value = value + 1; // shadow again println!("{}", value); // 11 — this is idiomatic Rust }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; // Zig does NOT allow shadowing — each name must be unique in its scope. // Use distinct names or a mutable var instead: const initial = 5; var doubled: i32 = initial * 2; doubled += 1; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{doubled})); }
Rust's variable shadowing — reusing a name to create a new binding after a type transform — is common and idiomatic. Zig forbids it: each name must be unique within its scope, and inner blocks may not shadow outer names either. Use distinct meaningful names or a var for mutation instead. This makes data flow explicit and avoids the confusion of two bindings named value referring to different things.
Strings
String slices (&str vs []const u8)
fn main() { let greeting: &str = "Hello"; let length = greeting.len(); println!("{} has {} bytes", greeting, length); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const greeting: []const u8 = "Hello"; const length = greeting.len; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{s} has {d} bytes\n", .{ greeting, length })); }
Zig's []const u8 is the direct equivalent of Rust's &str — a borrowed slice of bytes with a known length. In Zig, .len is a field, not a method call. String literals have type *const [N:0]u8 (null-terminated pointer) which coerces automatically to []const u8. Neither type owns its memory.
Owned strings (String vs ArrayList(u8))
fn main() { let mut message = String::new(); message.push_str("Hello"); message.push_str(", World!"); println!("{} (len={})", message, message.len()); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); var message = std.ArrayList(u8).empty; defer message.deinit(allocator); try message.appendSlice(allocator, "Hello"); try message.appendSlice(allocator, ", World!"); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{s} (len={d})\n", .{ message.items, message.items.len })); }
Rust's String is an owned heap-allocated byte buffer backed by the global allocator. In Zig 0.16.0, std.ArrayList(T) is the unmanaged dynamic array — it stores no allocator inside; pass one to each method and call defer message.deinit(allocator) to release. Initialize with .empty. There is no global allocator in Zig: every heap operation is explicit. message.items gives the []u8 slice of live contents.
String comparison
fn main() { let a = "hello"; let b = "hello"; if a == b { // == compares content in Rust println!("equal"); } println!("{}", a < b); // lexicographic }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const a = "hello"; const b = "hello"; if (std.mem.eql(u8, a, b)) { try stdout.writeStreamingAll(io, "equal\n"); } const ordering = std.mem.order(u8, a, b); if (ordering == .lt) { try stdout.writeStreamingAll(io, "a < b\n"); } else { try stdout.writeStreamingAll(io, "a >= b\n"); } }
In Rust, == on &str compares content. In Zig, == on slices compares only pointer identity — a common mistake coming from Rust. Use std.mem.eql(u8, a, b) for content comparison and std.mem.order(u8, a, b) for lexicographic ordering. The result of order is a std.math.Order enum with values .lt, .eq, and .gt.
String formatting
fn main() { let name = "World"; let count = 3; let msg = format!("{} has {} items", name, count); println!("{}", msg); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const name = "World"; const count = 3; var buf: [64]u8 = undefined; const message = try std.fmt.bufPrint(&buf, "{s} has {d} items\n", .{ name, count }); try stdout.writeStreamingAll(io, message); }
Rust's format! macro returns a heap-allocated String. Zig's std.fmt.bufPrint writes into a caller-provided stack buffer and returns a slice — no allocation. Format specifiers differ: {s} for strings and {d} for integers in Zig; Rust uses {} for both. Format strings are validated at compile time in both languages.
Arrays & Slices
Fixed-size arrays
fn main() { let numbers: [i32; 5] = [10, 20, 30, 40, 50]; let total: i32 = numbers.iter().sum(); println!("total: {}", total); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [5]i32{ 10, 20, 30, 40, 50 }; var total: i32 = 0; for (numbers) |value| total += value; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "total: {d}\n", .{total})); }
Rust writes fixed arrays as [T; N]; Zig writes them as [N]T — type and size are swapped. Both have the size as part of the type, so [5]i32 and [6]i32 are different, incompatible types. Use [_]i32{ ... } to let the compiler infer the size from the literal. Zig has no .iter() method — iterate directly with for (array) |value|.
Slices (&[T] vs []T)
fn print_numbers(values: &[i32]) { for &value in values { println!("{}", value); } } fn main() { let numbers = [1, 2, 3, 4, 5]; print_numbers(&numbers[1..4]); // elements 1, 2, 3 }
const std = @import("std"); fn print_numbers(io: std.Io, values: []const i32) !void { const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; for (values) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } } pub fn main(init: std.process.Init) !void { const numbers = [5]i32{ 1, 2, 3, 4, 5 }; try print_numbers(init.io, numbers[1..4]); // elements at index 1, 2, 3 }
Rust's &[T] and Zig's []T are both fat pointers (pointer + length). Slicing syntax is the same: array[1..4]. In Zig, functions that write output receive io: std.Io as a parameter — there is no global stdout handle. The type []const T means the slice is immutable (the direct equivalent of Rust's &[T]).
Dynamic arrays (Vec vs ArrayList)
fn main() { let mut numbers: Vec<i32> = Vec::new(); numbers.push(1); numbers.push(2); numbers.push(3); println!("{:?} (len={})", numbers, numbers.len()); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); var numbers = std.ArrayList(i32).empty; defer numbers.deinit(allocator); try numbers.append(allocator, 1); try numbers.append(allocator, 2); try numbers.append(allocator, 3); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "len={d} first={d}\n", .{ numbers.items.len, numbers.items[0] })); }
Rust's Vec<T> is a heap-allocated dynamic array backed by the global allocator. In Zig 0.16.0, std.ArrayList(T) is an unmanaged dynamic array: you pass the allocator to each method call rather than storing it in the struct. Initialize with .empty. numbers.items gives the underlying []T slice. There is no {:?} debug formatter in Zig; print individual fields with {d}, {s}, or {any}.
Iterating with index
fn main() { let names = ["Alice", "Bob", "Carol"]; for name in &names { println!("{}", name); } for (index, name) in names.iter().enumerate() { println!("{}: {}", index, name); } }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const names = [3][]const u8{ "Alice", "Bob", "Carol" }; for (names) |name| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{s}\n", .{name})); } for (names, 0..) |name, index| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}: {s}\n", .{ index, name })); } }
Rust's iter().enumerate() gives (index, reference) pairs. Zig uses for (slice, 0..) |item, index| — the 0.. is a special open-ended range that supplies the iteration index. Zig's for always iterates over a sequence directly; for C-style counting loops, use while with a counter variable.
Control Flow
if as an expression
fn main() { let score = 75; let grade = if score >= 60 { "pass" } else { "fail" }; println!("{}", grade); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const score = 75; const grade = if (score >= 60) "pass" else "fail"; try stdout.writeStreamingAll(io, grade); try stdout.writeStreamingAll(io, "\n"); }
Both Rust and Zig treat if as an expression that produces a value. The syntax differs: Zig requires parentheses around the condition and omits the braces for single-expression branches. Unlike Rust, Zig has no ternary ?: operator — if (cond) a else b is the idiomatic form. Both branches must produce the same type.
while loops
fn main() { let mut counter = 0; while counter < 5 { println!("{}", counter); counter += 1; } }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [8]u8 = undefined; var counter: usize = 0; while (counter < 5) : (counter += 1) { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{counter})); } }
Zig's while has an optional "continue expression" in : (expr) syntax placed after the condition. This expression runs after every iteration — including after continue — unlike an increment at the bottom of the loop body, which continue would skip. There is no Rust-style infinite loop { break value }; use a labeled block for that pattern.
Numeric range loops
fn main() { for i in 0..5 { print!("{} ", i); } println!(); for i in (0..5).rev() { print!("{} ", i); } println!(); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [8]u8 = undefined; // Zig for loops only iterate over slices — use while for numeric ranges var i: usize = 0; while (i < 5) : (i += 1) { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} ", .{i})); } try stdout.writeStreamingAll(io, "\n"); var j: usize = 5; while (j > 0) { j -= 1; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} ", .{j})); } try stdout.writeStreamingAll(io, "\n"); }
Rust's for i in 0..5 is a range loop using the Iterator protocol. Zig has no equivalent — for only iterates over arrays and slices, not abstract iterators. Numeric ranges require a while loop with a counter. This is intentional: Zig eliminates implicit iterator state. The for (slice, 0..) form provides indices, but there is no range() function.
match vs switch (exhaustive)
fn main() { let value = 3; let description = match value { 1 => "one", 2 | 3 => "two or three", 4..=9 => "four to nine", _ => "other", }; println!("{}", description); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const value = 3; const description = switch (value) { 1 => "one", 2, 3 => "two or three", // comma for alternatives, not | 4...9 => "four to nine", // three dots for inclusive range else => "other", }; try stdout.writeStreamingAll(io, description); try stdout.writeStreamingAll(io, "\n"); }
Zig's switch is the equivalent of Rust's match — both are exhaustive expressions. Syntax differences: Zig uses commas for multiple values (2, 3) where Rust uses |, and ... (three dots) for inclusive ranges where Rust uses ..=. The else branch replaces Rust's _. Zig switch arms cannot bind variables; use if (opt) |value| for that pattern.
Labeled blocks with break value
fn main() { let result = 'search: { let mut i = 0; while i < 10 { if i * i > 50 { break 'search i; } i += 1; } 0 }; println!("{}", result); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const result = search: { var i: i32 = 0; while (i < 10) : (i += 1) { if (i * i > 50) break :search i; } break :search 0; }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{result})); }
Both Rust and Zig support labeled blocks that produce values via break. The syntax is close but mirrored: Rust uses 'label (lifetime-style prefix), while Zig uses label: (colon suffix before the block) and break :label value. In Rust, the last expression of a block is its value implicitly; in Zig every exit path must use an explicit break :label value.
Functions
Function definitions
fn add(a: i32, b: i32) -> i32 { a + b // implicit return — last expression } fn main() { println!("{}", add(3, 4)); }
const std = @import("std"); fn add(a: i32, b: i32) i32 { return a + b; // explicit return required } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{add(3, 4)})); }
Zig function syntax is fn name(param: Type) ReturnType { ... } — no -> arrow. The return type comes directly after the parameter list. Zig requires an explicit return; there are no implicit-return expressions as in Rust. All parameters are immutable by default; to mutate a caller's value, accept a pointer parameter.
Function pointers
fn apply(operation: fn(i32) -> i32, number: i32) -> i32 { operation(number) } fn double(number: i32) -> i32 { number * 2 } fn triple(number: i32) -> i32 { number * 3 } fn main() { println!("{} {}", apply(double, 5), apply(triple, 5)); }
const std = @import("std"); fn apply(operation: *const fn (i32) i32, number: i32) i32 { return operation(number); } fn double(number: i32) i32 { return number * 2; } fn triple(number: i32) i32 { return number * 3; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d}\n", .{ apply(&double, 5), apply(&triple, 5) })); }
Rust function pointer types are fn(i32) -> i32; Zig's are *const fn(i32) i32 — a pointer to a constant function, without the arrow. Pass a function's address with &function_name. Zig functions are not first-class values without the pointer; you cannot store a bare function in a variable directly.
No closures — use structs
fn apply_twice(operation: impl Fn(i32) -> i32, value: i32) -> i32 { operation(operation(value)) } fn main() { let offset = 10; let result = apply_twice(|x| x + offset, 5); // closure captures offset println!("{}", result); // 25 }
const std = @import("std"); // Zig has no closures — capture state by making a struct whose method does the work const Adder = struct { offset: i32, fn call(self: Adder, value: i32) i32 { return value + self.offset; } }; fn apply_twice(adder: Adder, value: i32) i32 { return adder.call(adder.call(value)); } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const adder = Adder{ .offset = 10 }; const result = apply_twice(adder, 5); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{result})); }
Zig has no closures. When Rust code captures variables in a closure, the Zig equivalent is a struct whose fields hold the captured values and a method that does the work. This is more verbose but makes the captured state explicit and avoids hidden allocations. When no state needs to be captured, a plain function pointer (*const fn(i32) i32) is sufficient.
Multiple return values
fn min_max(numbers: &[i32]) -> (i32, i32) { let minimum = *numbers.iter().min().unwrap(); let maximum = *numbers.iter().max().unwrap(); (minimum, maximum) } fn main() { let numbers = [3, 1, 4, 1, 5, 9]; let (minimum, maximum) = min_max(&numbers); println!("min={} max={}", minimum, maximum); }
const std = @import("std"); fn min_max(numbers: []const i32) struct { min: i32, max: i32 } { var minimum = numbers[0]; var maximum = numbers[0]; for (numbers[1..]) |value| { if (value < minimum) minimum = value; if (value > maximum) maximum = value; } return .{ .min = minimum, .max = maximum }; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [6]i32{ 3, 1, 4, 1, 5, 9 }; const result = min_max(&numbers); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "min={d} max={d}\n", .{ result.min, result.max })); }
Rust returns multiple values as tuples, destructured with let (a, b) = .... Zig uses anonymous struct literals (.{ .min = value, .max = value }) — the return type is written inline as a struct type. Access fields with .min and .max. Zig has no tuple syntax; anonymous structs fill the same role with named fields, which are more self-documenting.
Structs & Methods
Struct definitions
struct Point { x: f32, y: f32, } fn main() { let origin = Point { x: 0.0, y: 0.0 }; let corner = Point { x: 3.0, y: 4.0 }; println!("({}, {})", corner.x, corner.y); let _ = origin; }
const std = @import("std"); const Point = struct { x: f32, y: f32, }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const origin = Point{ .x = 0, .y = 0 }; const corner = Point{ .x = 3, .y = 4 }; _ = origin; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "({d}, {d})\n", .{ corner.x, corner.y })); }
Zig struct definitions use const Name = struct { ... };. Struct literals always require named fields (.x = value) — positional initialization is not allowed, which prevents the common C/Rust mistake of swapping fields with the same type. Rust uses Point { x: 3.0 } (colon) while Zig uses Point{ .x = 3 } (leading dot, no colon). Structs are always value types in both languages.
Methods (impl vs methods inside struct)
struct Point { x: f32, y: f32 } impl Point { fn new(x: f32, y: f32) -> Self { Self { x, y } } fn distance(&self) -> f32 { (self.x * self.x + self.y * self.y).sqrt() } } fn main() { let corner = Point::new(3.0, 4.0); println!("{}", corner.distance()); }
const std = @import("std"); const Point = struct { x: f32, y: f32, fn new(x: f32, y: f32) Point { return Point{ .x = x, .y = y }; } fn distance(self: Point) f32 { return std.math.sqrt(self.x * self.x + self.y * self.y); } }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const corner = Point.new(3, 4); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d:.1}\n", .{corner.distance()})); }
In Rust, methods live in a separate impl block. In Zig, methods are ordinary functions defined inside the struct body — there is no separate impl block. A value receiver self: Point maps to Rust's self: Self; use self: *Point for mutation (like Rust's &mut self). Associated functions (Rust's fn new() -> Self) are just functions without a self parameter.
pub visibility
pub struct Rectangle { pub width: f32, pub height: f32, } impl Rectangle { pub fn area(&self) -> f32 { self.width * self.height } } fn main() { let rect = Rectangle { width: 3.0, height: 4.0 }; println!("{}", rect.area()); }
const std = @import("std"); const Rectangle = struct { width: f32, // fields are public by default in Zig height: f32, pub fn area(self: Rectangle) f32 { // methods are private unless pub return self.width * self.height; } }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const rect = Rectangle{ .width = 3, .height = 4 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d:.1}\n", .{rect.area()})); }
Both languages use pub for public visibility. In Zig, struct fields are public by default; methods are private unless marked pub. In Rust, both fields and methods are private by default and require pub. Zig files are modules — everything in a file can be accessed by other files that @import it, with pub controlling what is re-exported.
Default field values
#[derive(Default, Debug)] struct Config { timeout: u32, retries: u32, verbose: bool, } fn main() { let config = Config { timeout: 30, ..Config::default() }; println!("{:?}", config); }
const std = @import("std"); const Config = struct { timeout: u32 = 0, retries: u32 = 3, verbose: bool = false, }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; // Omitted fields take their default values const config = Config{ .timeout = 30 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "timeout={d} retries={d} verbose={}\n", .{ config.timeout, config.retries, config.verbose })); }
Rust's #[derive(Default)] generates a default() method based on each field's Default implementation. In Zig, default values are written inline in the struct definition (field: Type = value), and any field with a default can be omitted from the struct literal. There are no derive macros in Zig — default values, equality, and printing are always explicit.
Enums & Tagged Unions
Simple enumerations
enum Direction { North, South, East, West } fn main() { let heading = Direction::North; match heading { Direction::North => println!("north"), Direction::South => println!("south"), Direction::East => println!("east"), Direction::West => println!("west"), } }
const std = @import("std"); const Direction = enum { north, south, east, west }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const heading = Direction.north; const name = switch (heading) { .north => "north", .south => "south", .east => "east", .west => "west", }; try stdout.writeStreamingAll(io, name); try stdout.writeStreamingAll(io, "\n"); }
Zig enum variants are conventionally lowercase; Rust uses PascalCase. In Zig, variants are accessed as Direction.north or just .north when the type is inferred from context. Zig enum values are not integers by default — use @intFromEnum(value) to convert. The switch is exhaustive; omitting any variant is a compile error, as with Rust's match.
Data-carrying variants (union(enum))
enum Shape { Circle { radius: f32 }, Rectangle { width: f32, height: f32 }, } fn area(shape: &Shape) -> f32 { match shape { Shape::Circle { radius } => std::f32::consts::PI * radius * radius, Shape::Rectangle { width, height } => width * height, } } fn main() { let circle = Shape::Circle { radius: 5.0 }; println!("{:.2}", area(&circle)); }
const std = @import("std"); const Shape = union(enum) { circle: f32, rectangle: struct { width: f32, height: f32 }, }; fn area(shape: Shape) f32 { return switch (shape) { .circle => |radius| std.math.pi * radius * radius, .rectangle => |rect| rect.width * rect.height, }; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const circle = Shape{ .circle = 5.0 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d:.2}\n", .{area(circle)})); }
Rust's enum variants that carry data are tagged unions. Zig's equivalent is union(enum) — a union whose active tag is automatically tracked as an enum. The payload is captured in switch with |value|. Construction is Shape{ .circle = 5.0 } rather than Shape::Circle { radius: 5.0 }. Accessing the wrong field at runtime panics in debug builds.
Methods on enums
enum Coin { Penny, Nickel, Dime, Quarter } impl Coin { fn value_in_cents(&self) -> u32 { match self { Coin::Penny => 1, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } } } fn main() { println!("{}", Coin::Quarter.value_in_cents()); }
const std = @import("std"); const Coin = enum { penny, nickel, dime, quarter, fn value_in_cents(self: Coin) u32 { return switch (self) { .penny => 1, .nickel => 5, .dime => 10, .quarter => 25, }; } }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [8]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{Coin.quarter.value_in_cents()})); }
Zig enums support methods defined inside the enum body — just as struct methods live inside struct. There is no separate impl block. self: Coin is the value receiver. The switch on self is exhaustive: adding a new variant immediately breaks all switch statements that omit it, which the compiler flags as a compile error.
Optionals
Option<T> vs ?T
fn find_index(numbers: &[i32], target: i32) -> Option<usize> { numbers.iter().position(|&x| x == target) } fn main() { let numbers = [10, 20, 30]; match find_index(&numbers, 20) { Some(index) => println!("found at {}", index), None => println!("not found"), } }
const std = @import("std"); fn find_index(numbers: []const i32, target: i32) ?usize { for (numbers, 0..) |value, index| { if (value == target) return index; } return null; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [3]i32{ 10, 20, 30 }; if (find_index(&numbers, 20)) |index| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "found at {d}\n", .{index})); } else { try stdout.writeStreamingAll(io, "not found\n"); } }
Rust's Option<T> becomes Zig's ?T — much more concise. Some(x) and None map to a non-null value and null. In Zig, null is only valid for optional types; assigning it to a non-optional is a compile error. The if (optional) |value| syntax is the direct equivalent of Rust's if let Some(value) = optional.
if let / while let
fn main() { let maybe_number: Option<i32> = Some(42); if let Some(number) = maybe_number { println!("got: {}", number); } let mut stack = vec![1, 2, 3]; while let Some(top) = stack.pop() { println!("{}", top); } }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const maybe_number: ?i32 = 42; if (maybe_number) |number| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "got: {d}\n", .{number})); } // while (optional) |value| iterates until null const items = [3]?i32{ 3, 2, 1 }; for (items) |maybe_item| { if (maybe_item) |item| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{item})); } } }
Rust's if let Some(x) = opt unwraps an optional inline. Zig's if (opt) |value| does the same — inside the block, value has type T, not ?T. Rust's while let Some(x) = opt iterates until None; Zig uses while (opt) |value| for the same pattern with iterator functions that return null when exhausted.
unwrap_or vs orelse
fn main() { let maybe_port: Option<u16> = None; let port = maybe_port.unwrap_or(8080); println!("{}", port); // unwrap() panics on None — so does optional.? in Zig // let port2 = maybe_port.unwrap(); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const maybe_port: ?u16 = null; // orelse is unwrap_or const port = maybe_port orelse 8080; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{port})); // orelse can also contain a block, return, or break: const port2 = maybe_port orelse blk: { break :blk @as(u16, 8080); }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{port2})); // optional.? panics if null — like Rust's unwrap() const definite: ?u16 = 9090; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{definite.?})); }
Rust's unwrap_or(default) maps to Zig's orelse operator: optional orelse default. The right-hand side of orelse can be a block expression, a return, or a break — making early-exit idioms concise. Rust's unwrap() (panic on None) maps to the postfix .? operator: optional.? panics if the value is null.
Propagating absence
fn get_city(user_id: u32) -> Option<String> { let user = find_user(user_id)?; // ? propagates None let address = user.address?; Some(address.city.clone()) }
// Zig has no ? propagation for optionals. // Use orelse return null at each step: fn get_city(user_id: u32) ?[]const u8 { const user = find_user(user_id) orelse return null; const address = user.address orelse return null; return address.city; }
Rust's ? operator propagates both None from Option<T> and Err from Result<T, E>. In Zig, try propagates errors but there is no equivalent for optionals — use orelse return null to propagate absence manually. This is more verbose but explicit: the code makes clear at each step that propagation can occur.
Error Handling
Error types (enum vs error set)
use std::fmt; #[derive(Debug)] enum AppError { InvalidInput, NotFound } impl fmt::Display for AppError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::InvalidInput => write!(formatter, "invalid input"), AppError::NotFound => write!(formatter, "not found"), } } } fn main() { println!("{}", AppError::NotFound); }
const std = @import("std"); const AppError = error{ InvalidInput, NotFound }; fn might_fail(succeed: bool) AppError!i32 { if (!succeed) return AppError.NotFound; return 42; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; if (might_fail(false)) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "value: {d}\n", .{value})); } else |err| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "error: {s}\n", .{@errorName(err)})); } }
Rust errors are typically enums that implement std::error::Error and Display. Zig error sets (error{ A, B }) are simpler: they are sets of named error values, not arbitrary structs. Zig errors carry no attached data — for detailed context, pass context alongside the error through separate channels. @errorName(err) converts an error to its name string.
Result<T,E> vs error union (!T)
fn parse_port(input: &str) -> Result<u16, String> { let number: u32 = input.parse() .map_err(|_| "not a number".to_string())?; if number > 65535 { return Err("port out of range".to_string()); } Ok(number as u16) } fn main() { match parse_port("8080") { Ok(port) => println!("port: {}", port), Err(msg) => println!("error: {}", msg), } }
const std = @import("std"); const ParseError = error{ NotANumber, OutOfRange }; fn parse_port(input: []const u8) ParseError!u16 { const number = std.fmt.parseInt(u32, input, 10) catch return ParseError.NotANumber; if (number > 65535) return ParseError.OutOfRange; return @intCast(number); } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; if (parse_port("8080")) |port| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "port: {d}\n", .{port})); } else |err| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "error: {s}\n", .{@errorName(err)})); } }
Rust's Result<T, E> becomes Zig's E!T — an error union. The success type comes after the !: ParseError!u16 means "either a ParseError or a u16". The inferred form !void uses anyerror, which covers any error the function can return. The if (result) |value| ... else |err| ... pattern matches on success or error.
? vs try — error propagation
use std::num::ParseIntError; fn double_str(input: &str) -> Result<i32, ParseIntError> { let number = input.parse::<i32>()?; // ? propagates the error Ok(number * 2) } fn main() { println!("{:?}", double_str("21")); println!("{:?}", double_str("oops")); }
const std = @import("std"); fn double_str(input: []const u8) !i32 { const number = try std.fmt.parseInt(i32, input, 10); // try propagates the error return number * 2; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; if (double_str("21")) |result| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{result})); } else |err| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "error: {s}\n", .{@errorName(err)})); } if (double_str("oops")) |_| {} else |err| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "error: {s}\n", .{@errorName(err)})); } }
Rust's postfix ? and Zig's prefix try both propagate errors up the call stack. try expr is syntactic sugar for expr catch |err| return err. Unlike Rust's ?, Zig's try only works with error unions — not optionals. The compiler tracks the set of errors that can propagate so the return type is always precise.
Handling errors inline (catch)
fn parse(input: &str) -> i32 { input.parse().unwrap_or(0) // default on error } fn main() { println!("{}", parse("42")); // 42 println!("{}", parse("bad")); // 0 }
const std = @import("std"); fn parse(input: []const u8) i32 { return std.fmt.parseInt(i32, input, 10) catch 0; // default on error } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{parse("42")})); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{parse("bad")})); }
Rust's .unwrap_or(default) on a Result maps to Zig's catch default. For more control, use catch |err| { ... } to inspect the error before handling: parseInt(...) catch |err| { log(err); return 0; }. The catch block can also re-propagate with return err. This mirrors Rust's match result { Ok(v) => v, Err(e) => ... }.
errdefer — cleanup only on error
// In Rust, Drop handles cleanup automatically — even on error. // No explicit cleanup code needed: fn make_buffer(message: &str) -> Vec<u8> { let mut buffer = Vec::with_capacity(message.len() + 1); buffer.extend_from_slice(message.as_bytes()); buffer.push(b'\n'); buffer // returned — caller takes ownership } fn main() { let data = make_buffer("hello"); print!("{}", String::from_utf8_lossy(&data)); }
const std = @import("std"); fn make_buffer(allocator: std.mem.Allocator, message: []const u8) ![]u8 { const buffer = try allocator.alloc(u8, message.len + 1); errdefer allocator.free(buffer); // freed ONLY if this function returns an error @memcpy(buffer[0..message.len], message); buffer[message.len] = '\n'; return buffer; // caller must free this } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const data = try make_buffer(allocator, "hello"); defer allocator.free(data); try stdout.writeStreamingAll(io, data); }
In Rust, Drop automatically frees heap memory when an owning variable goes out of scope — even on error. Zig has no Drop trait; cleanup is explicit via defer (always runs on scope exit) and errdefer (runs only when the enclosing function returns an error). errdefer eliminates the C pattern of duplicating cleanup on every error path and is Zig's answer to Drop for error cases.
Memory & Allocators
No hidden allocation
fn main() { // These allocate from the global allocator invisibly: let text: String = "hello".to_string(); // heap alloc let items: Vec<i32> = vec![1, 2, 3]; // heap alloc // Rust's ownership tracks and frees both automatically println!("{} {:?}", text, items); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; // In Zig, every allocation requires an explicit allocator parameter. // These alternatives use no heap: const text: []const u8 = "hello"; // string slice — stack only const items = [3]i32{ 1, 2, 3 }; // fixed array — stack only try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{s} first={d}\n", .{ text, items[0] })); }
Rust allows implicit heap allocation via .to_string(), vec![], and many other methods backed by the global allocator. Zig forbids hidden allocation entirely: every heap allocation requires an explicit std.mem.Allocator parameter. If a function does not accept an allocator, it cannot allocate. This makes allocation visible in the call graph and enables embedding Zig in environments with no heap (microcontrollers, kernels, game consoles).
Heap allocation (Box vs allocator.create)
fn main() { let boxed_value: Box<i32> = Box::new(42); // heap-allocated println!("{}", *boxed_value); // dropped here — memory freed automatically }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const boxed_value: *i32 = try allocator.create(i32); defer allocator.destroy(boxed_value); boxed_value.* = 42; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{boxed_value.*})); }
Rust's Box<T> is a heap-allocated pointer that frees on Drop. Zig's allocator.create(T) returns a *T — a non-null pointer to a single heap-allocated value. Free it with allocator.destroy(pointer). The defer allocator.destroy(ptr) idiom provides the same safety as Box's Drop implementation, but is explicit in the source.
Ownership vs explicit allocators
fn make_greeting(name: &str) -> String { format!("Hello, {}!", name) // returned String owns its heap memory; // the caller's binding will free it on drop } fn main() { let greeting = make_greeting("World"); println!("{}", greeting); } // greeting dropped here — freed
const std = @import("std"); fn make_greeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 { // allocPrint allocates exactly enough memory for the formatted string return try std.fmt.allocPrint(allocator, "Hello, {s}!", .{name}); // caller is responsible for calling allocator.free on the returned slice } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const greeting = try make_greeting(allocator, "World"); defer allocator.free(greeting); try stdout.writeStreamingAll(io, greeting); try stdout.writeStreamingAll(io, "\n"); }
Rust's ownership model automatically tracks who owns heap memory and frees it at scope exit via Drop. Zig has no ownership model — memory is managed explicitly via allocator parameters. std.fmt.allocPrint allocates a heap string and the caller must call allocator.free. The defer allocator.free(result) idiom immediately after obtaining an allocation is the safe Zig pattern.
Arena allocators
// Rust std has no built-in arena; use the 'bumpalo' crate: // use bumpalo::Bump; // let arena = Bump::new(); // let value = arena.alloc(42_i32); // let items = arena.alloc_slice_copy(&[1, 2, 3]); // // Everything freed when arena drops fn main() { println!("(arena requires bumpalo crate in Rust std)"); }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; // ArenaAllocator: allocate many items; free all at once var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); // frees ALL allocations from this arena const allocator = arena.allocator(); const values = try allocator.alloc(i32, 5); for (values, 0..) |*item, index| item.* = @intCast(index * index); var total: i32 = 0; for (values) |value| total += value; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "total: {d}\n", .{total})); // arena.deinit() frees values and everything else allocated here }
Arena allocators batch many allocations under one lifetime and free them all at once. Rust needs the external bumpalo crate; Zig ships std.heap.ArenaAllocator in the standard library. Because the allocator is an interface, you pass arena.allocator() to any function that takes std.mem.Allocator — the function has no idea it is talking to an arena. This is the allocator-as-parameter design paying off.
Comptime & Generics
Generics (comptime T: type)
fn largest<T: PartialOrd>(slice: &[T]) -> &T { let mut result = &slice[0]; for item in slice { if item > result { result = item; } } result } fn main() { let numbers = [3, 1, 4, 1, 5, 9]; println!("{}", largest(&numbers)); }
const std = @import("std"); fn largest(comptime T: type, slice: []const T) T { var result = slice[0]; for (slice[1..]) |item| { if (item > result) result = item; } return result; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const numbers = [8]i32{ 3, 1, 4, 1, 5, 9, 2, 6 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{largest(i32, &numbers)})); }
Rust generics use <T: Trait> bounds enforced by the trait system. Zig uses comptime T: type — a compile-time parameter that accepts any type. There are no trait bounds; if the type supports >, the function compiles for it (duck typing at compile time). The compiler generates a concrete function for each type used, same as Rust monomorphization, but without a separate trait system.
const fn vs comptime evaluation
const fn factorial(number: u64) -> u64 { if number <= 1 { 1 } else { number * factorial(number - 1) } } const PRECOMPUTED: u64 = factorial(10); fn main() { println!("{}", PRECOMPUTED); // computed at compile time println!("{}", factorial(15)); // called at runtime }
const std = @import("std"); fn factorial(number: u64) u64 { if (number <= 1) return 1; return number * factorial(number - 1); } // Any function called with a comptime-known argument is evaluated at compile time. // No separate const fn annotation needed: const precomputed: u64 = factorial(10); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{precomputed})); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{factorial(15)})); }
Rust requires the const fn annotation to mark a function as usable at compile time. In Zig, any function whose arguments are compile-time known is automatically evaluated at compile time — there is no separate annotation. A top-level const initializer is always evaluated at compile time. The same function can be called at compile time (when arguments are comptime) or runtime (when arguments are not).
Conditional compilation (cfg! vs comptime)
fn log(message: &str) { if cfg!(debug_assertions) { eprintln!("[DEBUG] {}", message); } } fn main() { log("starting up"); println!("running"); }
const std = @import("std"); const debug_mode = false; // change to true to enable debug output fn log(io: std.Io, message: []const u8) !void { if (comptime debug_mode) { const stdout = std.Io.File.stdout(); var buf: [128]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "[DEBUG] {s}\n", .{message})); } } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); try log(io, "starting up"); try stdout.writeStreamingAll(io, "running\n"); }
Rust uses #[cfg(debug_assertions)] and cfg!() macros for conditional compilation. In Zig, if (comptime condition) evaluates the condition at compile time — the dead branch is never compiled at all. This is regular Zig code, not a macro or attribute system. @import("builtin").mode gives the optimization level (Debug, ReleaseSafe, etc.), equivalent to Rust's debug_assertions.
Type reflection at compile time
fn main() { let value: i32 = 42; let type_name = std::any::type_name::<i32>(); println!("type: {type_name}, value: {value}"); // Rust's TypeId is runtime-only; deep reflection needs macros or proc-macros }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const value: i32 = 42; // @typeName gives the type's name as a string literal const type_name = @typeName(@TypeOf(value)); // @typeInfo gives a tagged union with all details about the type const info = @typeInfo(@TypeOf(value)); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "type: {s}, bits: {d}, value: {d}\n", .{ type_name, info.int.bits, value })); }
Rust's std::any::type_name gives a runtime string; deep reflection requires derive macros or proc-macros. Zig's @typeInfo(T) returns a std.builtin.Type tagged union at compile time — you can inspect bit widths, struct fields, enum variants, function signatures, and more, without any macro system. @TypeOf(expr) gives the compile-time type of an expression. Zig's compile-time reflection is a first-class language feature.