Ruby.CodeCompared.To/Zig

An interactive executable cheatsheet for Rubyists learning Zig

Ruby 4.0 Zig 0.15.2
Syntax Basics
Hello World
puts "Hello, Zig!"
const std = @import("std"); pub fn main() !void { try std.fs.File.stdout().writeAll("Hello, Zig!\n"); }

Every Zig program begins with const std = @import("std"); β€” there is no automatic prelude. The pub fn main() !void signature declares that main is public and may return an error (the ! prefix means "error union with void"). The try keyword propagates any error that stdout.print might return. Unlike Ruby's puts, Zig's writer does not add a newline automatically β€” \n must be explicit.

Comments
# Single-line comment count = 42 # inline comment =begin Multi-line comment block (rarely used in practice) =end puts count
const std = @import("std"); pub fn main() !void { // Single-line comment const count = 42; // inline comment // Zig has no block comment syntax. // Multi-line comments use // on each line. // Doc comments (///) are only valid before declarations, not here. var buf: [32]u8 = undefined; const out = std.fs.File.stdout(); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{count})); }

Zig uses only // single-line comments β€” there is no block comment syntax like /* ... */. The triple-slash /// is a "doc comment" that attaches documentation to the declaration that follows it; it is only valid immediately before a declaration, not inside a function body. The intentional omission of block comments keeps the grammar simpler and avoids ambiguity when commenting out code that itself contains comments.

Variables (var vs const)
count = 10 # mutable local count += 1 puts count # 11 LIMIT = 100 # Ruby constant (convention) puts LIMIT
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; var count: i32 = 10; // mutable count += 1; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{count})); // 11 const limit: i32 = 100; // immutable try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{limit})); }

Zig enforces the var / const distinction at the compiler level β€” assigning to a const binding is a compile error, not merely a convention. In Ruby, LIMIT = 100 is a constant by convention and the interpreter only warns (not errors) when you reassign it. In Zig, every variable must have an explicit type annotation unless the type can be inferred from a literal (e.g. const x = 42; infers a comptime integer). All local variables must be used; an unused variable is also a compile error.

Output / printing
puts "With newline automatically" print "No automatic newline\n" printf "%d items at $%.2f each\n", 3, 4.99 $stderr.puts "Error message"
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; try out.writeAll("With newline\n"); try out.writeAll(try std.fmt.bufPrint(&buf, "{d} items at ${d:.2} each\n", .{ 3, 4.99 })); // std.debug.print goes to stderr std.debug.print("Debug: value={d}\n", .{42}); }

Zig's stdout.print uses format specifiers inside curly braces: {d} for integers, {s} for strings, {} for the default representation. The argument list is always passed as an anonymous struct literal .{arg1, arg2}. Note that std.debug.print writes to stderr, not stdout β€” it is intended for debugging and is not suitable for program output that other processes might consume. All stdout.print calls return an error and must be preceded by try.

Undefined values
# Ruby variables are nil until assigned x = nil puts x.nil? # true puts x.class # NilClass
const std = @import("std"); pub fn main() !void { // undefined leaves memory uninitialized β€” use only // when you will assign before reading (e.g. buffers). var buffer: [4]u8 = undefined; buffer[0] = 'Z'; buffer[1] = 'i'; buffer[2] = 'g'; buffer[3] = '!'; var out_buf: [256]u8 = undefined; const out = std.fs.File.stdout(); try out.writeAll(try std.fmt.bufPrint(&out_buf, "{s}\n", .{buffer})); }

Zig has no nil β€” the absence of a value is expressed through the optional type ?T (see the Types section). The undefined keyword is a special initializer that leaves memory uninitialized, telling the compiler "I will fill this in before I read it." In Debug and ReleaseSafe builds, Zig fills undefined memory with 0xaa bytes so that reads before assignment cause obvious corruption rather than silent bugs. Reading from undefined memory is still safety-undefined behavior and will trigger a panic in safe modes.

Types
Integer types
small = 42 big = 2**62 negative = -100 puts small.class # Integer puts big # 4611686018427387904 puts negative
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const small: i32 = 42; const big: i64 = 4611686018427387904; const negative: i32 = -100; const unsigned: u8 = 255; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{small})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{big})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{negative})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{unsigned})); }

Zig has explicit-width integer types: i8, i16, i32, i64, i128 (signed) and u8, u16, u32, u64, u128 (unsigned). Unlike Ruby's arbitrary-precision Integer, Zig integers have fixed ranges and will panic on overflow in Debug mode. Zig also supports arbitrary-width types like i7 or u3 β€” any bit width from 0 to 65535 is valid. The platform-pointer-sized types are usize and isize, equivalent to C's size_t.

Floating point
ratio = 3.14159 puts ratio.class # Float puts ratio * 2 puts ratio.round(2)
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const ratio: f64 = 3.14159; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{ratio * 2.0})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.2}\n", .{ratio})); const single: f32 = 2.718; try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.3}\n", .{single})); }

Zig provides f16, f32, f64, and f128 floating-point types. Ruby's Float is always a 64-bit IEEE 754 double. In Zig, the precision specifier {d:.2} in a format string controls decimal places directly β€” there is no equivalent of Ruby's round method. There are no implicit conversions between integer and float types: to assign a f32 value from an i32, you must use @floatFromInt(value).

Boolean
active = true inactive = false puts active && !inactive # true puts active || inactive # true puts active.class # TrueClass
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const active = true; const inactive = false; try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{active and !inactive})); // true try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{active or inactive})); // true try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{active})); }

Zig uses the keywords and, or, and ! for logical operations β€” there are no && or || operators (those are bitwise in Zig). The boolean type is bool, and its only values are true and false. Unlike Ruby, where any value except nil and false is truthy, Zig requires boolean expressions to be of type bool β€” an integer 1 is never implicitly truthy in an if condition.

Optional types (?T vs nil)
maybe = nil maybe = 42 if rand > 0.5 if maybe puts "Got: #{maybe}" else puts "Nothing" end value = maybe || 0 puts value
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; var maybe: ?i32 = null; maybe = 42; if (maybe) |value| { try out.writeAll(try std.fmt.bufPrint(&buf, "Got: {d}\n", .{value})); } else { try out.writeAll("Nothing\n"); } // orelse provides a default const number = maybe orelse 0; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{number})); }

Zig's optional type ?T is similar to Ruby's nilable values, but the compiler forces you to handle the null case before accessing the inner value. The if (maybe) |value| syntax unwraps the optional into value if it is non-null β€” there is no way to accidentally call a method on null as you can in Ruby. The orelse keyword provides a default: maybe orelse 0 returns 0 if maybe is null. This is the same guarantee that Ruby's &. safe navigation operator tries to provide, but enforced at compile time.

Type casting
whole = 42 ratio = whole.to_f # Integer β†’ Float puts ratio # 42.0 puts ratio.to_i # back to 42 puts "123".to_i + 1 # string β†’ integer
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const whole: i32 = 42; const ratio: f64 = @floatFromInt(whole); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{ratio})); // 42 const back: i32 = @intFromFloat(ratio); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{back})); // 42 // Widening: i32 β†’ i64 (always safe) const wider: i64 = @intCast(whole); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{wider})); }

Zig has no implicit type conversions β€” every conversion must be explicit using a builtin function. @floatFromInt converts an integer to a float, @intFromFloat truncates a float to an integer, and @intCast converts between integer widths (panicking in safe builds if the value does not fit). This is the opposite of Ruby, which converts freely with to_f, to_i, and to_s. The explicit-cast requirement catches a whole class of bugs at compile time that would silently produce wrong values in more permissive languages.

Strings
String literals (byte slices)
greeting = "Hello" puts greeting.class # String puts greeting.length # 5 puts greeting + ", Zig!" puts greeting[0] # H (single char)
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; // String literals are *const [N:0]u8 β€” null-terminated byte arrays const greeting = "Hello"; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{greeting})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{greeting.len})); // 5 try out.writeAll(try std.fmt.bufPrint(&buf, "{c}\n", .{greeting[0]})); // H // Concatenation at compile time with ++ const full = greeting ++ ", Zig!"; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{full})); }

Zig string literals are not objects β€” they are pointers to null-terminated byte arrays (*const [N:0]u8). They do not have methods; you use functions from std.mem, std.fmt, and std.ascii instead. The ++ operator concatenates two strings at compile time only β€” runtime concatenation requires a heap allocator. The length is accessed via .len, not a method call. This is fundamentally different from Ruby's String, which is a full object with rich mutation methods and automatic memory management.

Multi-line strings
poem = <<~HEREDOC Roses are red, Violets are blue, Zig has no GC, And neither do you. HEREDOC puts poem
const std = @import("std"); pub fn main() !void { // Each line starting with \\ is one line of the string. // The \\ prefix is stripped; a newline is added automatically. const poem = \\Roses are red, \\Violets are blue, \\Zig has no GC, \\And neither do you. ; var buf: [256]u8 = undefined; const out = std.fs.File.stdout(); try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{poem})); }

Zig's multi-line string syntax uses a \\ prefix on each line instead of delimiters. Each \\ line contributes its content plus a newline character to the string; the \\ itself is not part of the value. This makes it easy to write strings containing double-quote characters without escaping, and the indentation of the \\ markers is arbitrary and not included in the string. The resulting value is still a compile-time constant byte slice β€” not a runtime heap object.

Formatted output
name = "Alice" score = 98.6 puts "Name: #{name}, Score: #{score}" puts format("%-10s %6.2f", name, score) result = "PI is %.4f" % Math::PI puts result
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var line_buf: [256]u8 = undefined; const name = "Alice"; const score: f64 = 98.6; try out.writeAll(try std.fmt.bufPrint(&line_buf, "Name: {s}, Score: {d}\n", .{ name, score })); try out.writeAll(try std.fmt.bufPrint(&line_buf, "{s:<10} {d:>6.2}\n", .{ name, score })); var buffer: [64]u8 = undefined; const result = try std.fmt.bufPrint(&buffer, "PI is {d:.4}", .{std.math.pi}); try out.writeAll(try std.fmt.bufPrint(&line_buf, "{s}\n", .{result})); }

Zig's format string syntax places the type specifier and options together inside {}: {s} for strings, {d} for decimals, {d:.2} for two decimal places, {s:<10} for left-aligned in a 10-character field. To build a formatted string rather than print it directly, std.fmt.bufPrint writes into a fixed-size stack buffer, while std.fmt.allocPrint allocates on the heap. There is no string interpolation with #{} β€” formatting is always an explicit function call.

String comparison
greeting = "hello" puts greeting == "hello" # true puts greeting == "Hello" # false puts greeting.upcase # HELLO puts greeting.include?("ll") # true
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const greeting = "hello"; // == compares pointers, not content β€” use std.mem.eql try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{std.mem.eql(u8, greeting, "hello")})); // true try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{std.mem.eql(u8, greeting, "Hello")})); // false // Check for substring const has_ll = std.mem.indexOf(u8, greeting, "ll") != null; try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{has_ll})); // true }

Because Zig strings are byte slices (pointers plus length), the == operator compares memory addresses, not content. To compare string values you must use std.mem.eql(u8, a, b), which performs a byte-by-byte comparison. There are no string methods β€” all string operations are functions in the standard library: std.mem.indexOf for searching, std.ascii.toUppercase for case conversion, std.mem.startsWith and std.mem.endsWith for prefix/suffix checks.

Arrays & Slices
Fixed-size arrays
numbers = [1, 2, 3, 4, 5] puts numbers.length # 5 puts numbers[0] # 1 puts numbers[-1] # 5 (last) numbers[2] = 99 puts numbers.inspect
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; var numbers = [5]i32{ 1, 2, 3, 4, 5 }; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{numbers.len})); // 5 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{numbers[0]})); // 1 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{numbers[4]})); // 5 (last) numbers[2] = 99; try out.writeAll(try std.fmt.bufPrint(&buf, "{any}\n", .{numbers})); }

Zig arrays have a fixed size that is part of the type: [5]i32 and [3]i32 are different types. The size must be known at compile time. Unlike Ruby arrays, Zig arrays cannot grow β€” they are stack-allocated value types with no associated heap memory. Negative indexing (Ruby's numbers[-1]) does not exist in Zig; the last element is always numbers[numbers.len - 1]. Out-of-bounds access in Debug or ReleaseSafe mode triggers a panic rather than returning nil.

Slices
numbers = [1, 2, 3, 4, 5] part = numbers[1..3] # [2, 3, 4] puts part.inspect puts part.length # 3
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const numbers = [5]i32{ 1, 2, 3, 4, 5 }; // Slice: pointer + length, not a copy const part: []const i32 = numbers[1..4]; // [2, 3, 4] try out.writeAll(try std.fmt.bufPrint(&buf, "{any}\n", .{part})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{part.len})); // 3 // Full slice const everything = numbers[0..]; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{everything.len})); // 5 }

A Zig slice ([]T or []const T) is a fat pointer β€” a pair of (pointer, length) β€” that references a contiguous sequence of elements without owning them. Slicing numbers[1..4] creates a view into the original array from index 1 up to (but not including) index 4. Unlike Ruby's Array#slice, a Zig slice does not copy the data. Functions that accept a variable-length collection typically take a slice rather than an array, making them work with any source array of the correct element type.

Iterating with for
fruits = ["apple", "banana", "cherry"] fruits.each { |fruit| puts fruit } fruits.each_with_index { |fruit, index| puts "#{index}: #{fruit}" }
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const fruits = [_][]const u8{ "apple", "banana", "cherry" }; for (fruits) |fruit| { try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{fruit})); } for (fruits, 0..) |fruit, index| { try out.writeAll(try std.fmt.bufPrint(&buf, "{d}: {s}\n", .{ index, fruit })); } }

Zig's for loop iterates over a slice or array by capturing each element in a |capture| block. To get the index simultaneously, pass two iterables β€” the array and a range starting at 0.. β€” separated by a comma: for (items, 0..) |item, index|. The [_] syntax lets the compiler infer the array length. There is no equivalent of Ruby's Enumerable β€” operations like map and filter must be written as explicit loops, though the standard library provides some helpers in std.mem.

ArrayList (dynamic array)
numbers = [] numbers.push(1) numbers.push(2) numbers.push(3) puts numbers.inspect # [1, 2, 3] puts numbers.length # 3
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [512]u8 = undefined; var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); // In Zig 0.15, ArrayList no longer stores the allocator β€” pass it per-call var numbers: std.ArrayList(i32) = .{}; defer numbers.deinit(allocator); try numbers.append(allocator, 1); try numbers.append(allocator, 2); try numbers.append(allocator, 3); try out.writeAll(try std.fmt.bufPrint(&buf, "{any}\n", .{numbers.items})); // { 1, 2, 3 } try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{numbers.items.len})); // 3 }

Dynamic arrays in Zig require an explicit allocator because there is no garbage collector. std.ArrayList(T) is the standard dynamic array. It must be initialized with an allocator and explicitly freed with deinit(). The defer keyword ensures cleanup happens at scope exit even if an error occurs, which is idiomatic Zig. The underlying slice of items is accessed via .items. Every append call returns an error (if allocation fails) and requires try.

Control Flow
if / else (also as expression)
score = 85 grade = if score >= 90 then "A" elsif score >= 80 then "B" else "C" end puts grade # Ternary-style label = score > 50 ? "pass" : "fail" puts label
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const score: i32 = 85; const grade = if (score >= 90) "A" else if (score >= 80) "B" else "C"; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{grade})); // if is an expression β€” both branches must have the same type const label = if (score > 50) "pass" else "fail"; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{label})); }

Zig's if is an expression that can appear anywhere a value is expected, similar to Ruby's inline if. When used as an expression, both branches must produce the same type β€” the compiler will reject a mismatch. Unlike Ruby, the condition must always be of type bool; you cannot write if (count) where count is an integer. There is no ternary ?: operator β€” the if/else expression fills that role cleanly.

while loop
count = 0 while count < 5 print "#{count} " count += 1 end puts
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; var count: i32 = 0; while (count < 5) : (count += 1) { try out.writeAll(try std.fmt.bufPrint(&buf, "{d} ", .{count})); } try out.writeAll("\n"); }

Zig's while loop has an optional "continue expression" in parentheses after the condition: while (condition) : (continue_expr). The continue expression runs at the end of each iteration (including after continue statements, unlike if you put the update inside the body). This is idiomatic for counter-based loops. There is no do...while β€” use while (true) { ... if (done) break; } instead. Zig also has no loop { ... } keyword; while (true) is the idiom for infinite loops.

switch (exhaustive on enum)
day = :monday message = case day when :monday then "Start of the week" when :friday then "Almost weekend" when :saturday, :sunday then "Weekend!" else "Midweek" end puts message
const std = @import("std"); const Day = enum { monday, tuesday, wednesday, thursday, friday, saturday, sunday }; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const day = Day.monday; const message = switch (day) { .monday => "Start of the week", .friday => "Almost weekend", .saturday, .sunday => "Weekend!", else => "Midweek", }; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{message})); }

Zig's switch is exhaustive β€” the compiler requires every possible value to be handled, or an else catch-all to be present. This catches the "forgot a case" bug that case/when in Ruby silently ignores by returning nil. A switch is also an expression and can be assigned directly. Enum members are referenced with a leading dot (.monday) when the type is known from context. Multiple values can be matched in one arm by separating them with commas.

Labeled blocks
result = catch(:done) do (1..10).each do |outer| (1..10).each do |inner| throw :done, outer * inner if outer * inner > 20 end end 0 end puts result
const std = @import("std"); pub fn main() !void { // Labeled blocks are expressions that return a value via break const result = search: { var outer: i32 = 1; while (outer <= 10) : (outer += 1) { var inner: i32 = 1; while (inner <= 10) : (inner += 1) { if (outer * inner > 20) break :search outer * inner; } } break :search 0; }; var buf: [256]u8 = undefined; const out = std.fs.File.stdout(); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{result})); }

Zig's labeled blocks allow breaking out of nested loops and returning a value from any block expression. A label is an identifier followed by a colon placed before the block: search: {. Breaking with break :search value exits the labeled block and provides the block's value. This is more explicit than Ruby's throw/catch mechanism and replaces both labeled next/break from Ruby and the need for helper functions to exit nested loops.

Functions
Defining functions
def add(first, second) first + second end def greet(name = "World") "Hello, #{name}!" end puts add(3, 4) puts greet puts greet("Zig")
const std = @import("std"); fn add(first: i32, second: i32) i32 { return first + second; } fn greet(name: []const u8) []const u8 { _ = name; // suppresses "unused parameter" warning return "Hello!"; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{add(3, 4)})); try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{greet("Zig")})); }

Zig functions require explicit parameter types and a return type β€” there is no type inference for function signatures. Default parameter values do not exist; optional parameters are typically achieved through an options struct. Every parameter is immutable by default (it is a copy in Zig's value-type world). The _ = name; idiom discards an unused parameter to satisfy the compiler's requirement that all bindings be used. Functions not marked pub are private to their file.

Returning multiple values
def min_max(numbers) [numbers.min, numbers.max] end minimum, maximum = min_max([3, 1, 4, 1, 5, 9]) puts "min=#{minimum}, max=#{maximum}"
const std = @import("std"); const MinMax = struct { minimum: i32, maximum: i32 }; fn minMax(numbers: []const i32) MinMax { var minimum = numbers[0]; var maximum = numbers[0]; for (numbers) |number| { if (number < minimum) minimum = number; if (number > maximum) maximum = number; } return .{ .minimum = minimum, .maximum = maximum }; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const numbers = [_]i32{ 3, 1, 4, 1, 5, 9 }; const result = minMax(&numbers); try out.writeAll(try std.fmt.bufPrint(&buf, "min={d}, max={d}\n", .{ result.minimum, result.maximum })); }

Zig does not have multiple return values in the way Go does. The idiomatic approach is to define a small struct and return it. This is efficient β€” small structs are often returned in registers β€” and it gives the fields meaningful names. Anonymous struct literals .{ .minimum = x, .maximum = y } let you construct the return value without repeating the type name. In Ruby, returning an array and destructuring with a, b = func() achieves the same effect more concisely, but at the cost of losing field names.

defer
def open_resource puts "Opening resource" begin puts "Using resource" raise "oops" puts "Done" # skipped: error propagates ensure puts "Closing resource" # always runs, even on error end end begin open_resource rescue RuntimeError => error puts "Rescued: #{error.message}" # error propagated out end
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); try out.writeAll("Opening resource\n"); defer std.fs.File.stdout().writeAll("Closing resource\n") catch {}; try out.writeAll("Using resource\n"); try out.writeAll("Done\n"); // "Closing resource" prints here, at scope exit }

Zig's defer is similar to Ruby's ensure β€” the deferred statement runs when the enclosing scope exits, whether normally or due to an error. Multiple defer statements execute in reverse order (last-in, first-out). There is also errdefer, which runs only if the scope exits with an error β€” useful for rollback logic. Unlike Ruby's ensure, which is a block, Zig's defer takes a single expression, so multi-step cleanup typically uses a block: defer { cleanup1(); cleanup2(); }.

Recursion
def factorial(number) return 1 if number <= 1 number * factorial(number - 1) end puts factorial(5) # 120 puts factorial(10) # 3628800
const std = @import("std"); fn factorial(number: u64) u64 { if (number <= 1) return 1; return number * factorial(number - 1); } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{factorial(5)})); // 120 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{factorial(10)})); // 3628800 }

Zig supports recursion in the same straightforward way as Ruby. One important difference is stack size: Zig programs have a fixed stack (typically 8 MB), and deep recursion will overflow it just as in C. There is no tail-call optimization guarantee in Zig. For deeply recursive algorithms, an iterative approach with an explicit stack is preferred. In Debug mode, Zig does include stack overflow detection via guard pages that produces a clear panic message rather than silent corruption.

Error Handling
Error sets and !T
class ParseError < StandardError; end class RangeError < StandardError; end def parse_positive(text) begin value = Integer(text) rescue ArgumentError raise ParseError, "not a number" end raise RangeError, "must be positive" unless value > 0 value end puts parse_positive("42") # 42
const std = @import("std"); const ParseError = error{ NotANumber, NotPositive }; fn parsePositive(text: []const u8) ParseError!u64 { const value = std.fmt.parseInt(u64, text, 10) catch return ParseError.NotANumber; if (value == 0) return ParseError.NotPositive; return value; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; const result = try parsePositive("42"); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{result})); }

Zig errors are not exceptions β€” they are values. An error set is declared with const MyError = error { Foo, Bar };, and a function that can fail returns an error union type written as ErrorSet!ReturnType. The set of possible errors is part of the function's type signature, visible to callers at compile time. Unlike Ruby's exception hierarchy, which uses runtime objects, Zig errors have no payload β€” they are just enum-like tags. Additional context (a message, a line number) must be passed through other means such as a logging function.

try and catch
begin value = Integer("abc") rescue ArgumentError => error puts "Caught: #{error.message}" value = 0 end puts "Value: #{value}"
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; // try propagates the error up to the caller // catch handles it inline const value = std.fmt.parseInt(i32, "abc", 10) catch |err| blk: { try out.writeAll(try std.fmt.bufPrint(&buf, "Caught: {}\n", .{err})); break :blk 0; }; try out.writeAll(try std.fmt.bufPrint(&buf, "Value: {d}\n", .{value})); }

Zig uses two complementary keywords for error handling. try expr is shorthand for expr catch |err| return err β€” it propagates any error to the caller. expr catch |err| recovery handles the error inline and provides a fallback value. The catch block can be a single expression or a labeled block (blk: { ... break :blk value; }). Unlike Ruby's rescue, there is no stack unwinding β€” Zig errors are returned through the normal call stack and have zero runtime overhead compared to a successful return.

anyerror
# Ruby rescue StandardError catches most errors def risky_operation raise "something went wrong" rescue => error puts "Handled: #{error.class}: #{error.message}" end risky_operation
const std = @import("std"); // anyerror!void accepts errors from any error set fn riskyOperation() anyerror!void { return error.SomethingWentWrong; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [256]u8 = undefined; riskyOperation() catch |err| { try out.writeAll(try std.fmt.bufPrint(&buf, "Handled: {}\n", .{err})); }; }

The special anyerror type is a superset of all error sets β€” it can hold any error value in the program. Using anyerror!T as a return type accepts errors from any source, similar to how Ruby's rescue StandardError catches most exceptions. However, Zig prefers precise error sets because they document exactly what can go wrong and let the compiler verify exhaustive handling. The anyerror type is best reserved for interfaces and generic code where the exact errors are not known in advance.

Structs
Defining structs
Point = Struct.new(:x, :y) origin = Point.new(0, 0) target = Point.new(3, 4) puts origin.x # 0 puts target.y # 4
const std = @import("std"); const Point = struct { x: i32, y: i32, }; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const origin = Point{ .x = 0, .y = 0 }; const target = Point{ .x = 3, .y = 4 }; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{origin.x})); // 0 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{target.y})); // 4 }

Zig structs are value types, not reference types. When you assign a struct to a new variable, the entire struct is copied. This is the opposite of Ruby objects, which are always references β€” assigning b = a in Ruby gives b a reference to the same object. Struct fields are accessed with dot notation the same way as Ruby. All fields must be named β€” there are no positional-only fields. Default field values can be provided: const Point = struct { x: i32 = 0, y: i32 = 0 };.

Struct methods
Point = Struct.new(:x, :y) do def distance_from_origin Math.sqrt(x**2 + y**2) end def to_s = "(#{x}, #{y})" end point = Point.new(3, 4) puts point.distance_from_origin # 5.0 puts point # (3, 4)
const std = @import("std"); const Point = struct { x: f64, y: f64, pub fn distanceFromOrigin(self: Point) f64 { return std.math.sqrt(self.x * self.x + self.y * self.y); } pub fn format(self: Point, buf: []u8) ![]u8 { return std.fmt.bufPrint(buf, "({d:.1}, {d:.1})\n", .{ self.x, self.y }); } }; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const point = Point{ .x = 3.0, .y = 4.0 }; try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.1}\n", .{point.distanceFromOrigin()})); try out.writeAll(try point.format(&buf)); }

Zig structs can contain functions β€” when the first parameter is self: TypeName, the function can be called as a method with dot syntax. There is no self magic β€” it is just a named parameter and could be called anything, though self is the convention. Methods are not inherited; there is no class hierarchy. The pub keyword makes a method accessible outside the file. Zig has no equivalent to Ruby's to_s protocol β€” custom formatting is done by writing a print-style method or using std.fmt.Formatter.

Struct initialization patterns
Config = Struct.new(:host, :port, :timeout, keyword_init: true) do def self.default new(host: "localhost", port: 8080, timeout: 30) end end config = Config.default puts "#{config.host}:#{config.port}"
const std = @import("std"); const Config = struct { host: []const u8 = "localhost", port: u16 = 8080, timeout: u32 = 30, pub fn default() Config { return .{}; // all fields use defaults } }; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const config = Config.default(); try out.writeAll(try std.fmt.bufPrint(&buf, "{s}:{d}\n", .{ config.host, config.port })); // Override one field const custom = Config{ .port = 9090 }; try out.writeAll(try std.fmt.bufPrint(&buf, "{s}:{d}\n", .{ custom.host, custom.port })); }

Zig structs support default field values, making it easy to create "builder"-style patterns. The anonymous struct literal .{} with no field overrides produces a struct where every field takes its default value. Named fields can be selectively overridden: Config{ .port = 9090 } gives a struct with host and timeout at their defaults but port set to 9090. This pattern is common for configuration objects and closely mirrors Ruby's keyword arguments with defaults, but without keyword argument syntax β€” all fields are always named.

Memory
Allocators (no GC)
# Ruby's garbage collector handles all memory automatically words = [] 10.times { |index| words << "word#{index}" } puts words.first puts words.length # GC frees memory when words goes out of scope
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); // In Zig 0.15, ArrayList no longer stores the allocator β€” pass it per-call var words: std.ArrayList([]const u8) = .{}; defer words.deinit(allocator); try words.append(allocator, "word0"); try words.append(allocator, "word1"); try words.append(allocator, "word2"); try out.writeAll(try std.fmt.bufPrint(&buf, "{s}\n", .{words.items[0]})); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{words.items.len})); }

Zig has no garbage collector. Every heap allocation requires an explicit allocator, and every allocation must eventually be freed. The allocator pattern means you can swap allocators without changing the code that uses them β€” a library that accepts an Allocator parameter can use a general-purpose allocator in production and a fixed-buffer allocator in embedded or testing contexts. This is the fundamental trade-off compared to Ruby: Zig gives you complete control over memory layout and lifetime, but you must manage that lifetime yourself.

GeneralPurposeAllocator
# Ruby always uses its built-in GC allocator object = { key: "value" } puts object[:key] # GC cleans up automatically β€” no explicit free needed
const std = @import("std"); pub fn main() !void { // GeneralPurposeAllocator detects leaks and double-frees in debug mode var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer { const leaked = gpa.deinit(); if (leaked == .leak) std.debug.print("LEAK!\n", .{}); } const allocator = gpa.allocator(); const buffer = try allocator.alloc(u8, 16); defer allocator.free(buffer); @memset(buffer, 'Z'); try std.fs.File.stdout().writeAll(buffer); try std.fs.File.stdout().writeAll("\n"); }

The GeneralPurposeAllocator is Zig's debug allocator β€” in Debug and ReleaseSafe builds it tracks every allocation and reports leaks, use-after-free errors, and double-frees when deinit() is called. In ReleaseFast builds it degrades to a thin wrapper around the OS allocator with no overhead. The standard pattern is to declare it on the stack with var gpa = std.heap.GeneralPurposeAllocator(.{}){} and then call gpa.allocator() to get the allocator interface. For production builds where performance matters more than leak detection, std.heap.c_allocator or a custom allocator may be preferred.

ArenaAllocator
# Processing a "request" β€” all temp objects freed together def handle_request(input) parts = input.split reversed = parts.map(&:reverse) reversed.join(" ") end puts handle_request("hello world zig") # all intermediate objects collected by GC together
const std = @import("std"); pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); // Arena: free everything at once β€” ideal for request-scoped allocations var arena = std.heap.ArenaAllocator.init(gpa.allocator()); defer arena.deinit(); const arenaAllocator = arena.allocator(); const words = try std.fmt.allocPrint(arenaAllocator, "hello world zig", .{}); _ = words; const result = try std.fmt.allocPrint(arenaAllocator, "processed output", .{}); try std.fs.File.stdout().writeAll(result); try std.fs.File.stdout().writeAll("\n"); // All arena allocations freed by arena.deinit() }

The ArenaAllocator allows many allocations without individual frees β€” all memory is released at once when arena.deinit() is called. This pattern is ideal for request-scoped work (a web server request, a compiler pass, a game frame) where the lifetime of all temporary allocations is tied to a single event. It is significantly faster than general-purpose allocation for many small objects because it never calls free on individual items. This approximates the way Ruby's GC handles per-request objects in a web application, but with deterministic timing.

Comptime
comptime values
BUFFER_SIZE = 1024 HEADER = "=" * 40 # These are computed once at startup β€” not truly compile-time puts BUFFER_SIZE puts HEADER
const std = @import("std"); // Evaluated at compile time β€” baked into the binary const buffer_size: usize = 1024; const max_connections: usize = buffer_size / 64; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{buffer_size})); // 1024 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{max_connections})); // 16 // comptime block β€” code executed at compile time const header_len = comptime blk: { var length: usize = 0; for ("Hello, comptime!") |_| length += 1; break :blk length; }; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{header_len})); // 16 }

Zig's comptime system allows arbitrary computations to run during compilation, not at program startup. Any expression that operates only on compile-time-known values is automatically evaluated at compile time; the comptime keyword forces a block or expression to be evaluated at compile time and causes a compile error if it cannot be. Unlike Ruby's constants, which are evaluated once at load time but still involve runtime code, Zig comptime values have zero runtime cost β€” they are embedded directly in the binary as immediate values.

Generic functions (comptime type parameter)
# Ruby uses duck typing β€” any comparable type works def max_value(first, second) first > second ? first : second end puts max_value(3, 7) # 7 puts max_value(3.14, 2.72) # 3.14 puts max_value("a", "z") # z
const std = @import("std"); // comptime T makes this function generic β€” one implementation, many types fn maxValue(comptime T: type, first: T, second: T) T { return if (first > second) first else second; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{maxValue(i32, 3, 7)})); // 7 try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.2}\n", .{maxValue(f64, 3.14, 2.72)})); // 3.14 }

Zig achieves generics through comptime type parameters. A parameter declared comptime T: type receives a type as its value at compile time, and the compiler generates a specialized version of the function for each distinct type used. This is similar to C++ templates but with a much simpler mechanism β€” it is just normal Zig code that happens to run at compile time. Unlike Ruby's duck typing, which resolves method dispatch at runtime, Zig generics are fully resolved at compile time with no runtime cost and with complete type checking.

@TypeOf and @sizeOf
count = 42 ratio = 3.14 text = "hello" puts count.class # Integer puts ratio.class # Float puts text.class # String
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const count: i32 = 42; const ratio: f64 = 3.14; // @TypeOf returns the type at compile time try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{@TypeOf(count)})); // i32 try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{@TypeOf(ratio)})); // f64 // @sizeOf returns size in bytes at compile time try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{@sizeOf(i32)})); // 4 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{@sizeOf(f64)})); // 8 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{@sizeOf(bool)})); // 1 }

Zig's @TypeOf(expr) and @sizeOf(T) are builtin functions that execute at compile time. @TypeOf returns the type of an expression, which is useful in generic code to capture and pass along a type. @sizeOf returns the number of bytes a type occupies in memory β€” information that does not exist in Ruby at all (since Ruby objects are always heap-allocated references with opaque internal structure). These builtins let you write self-describing, portable code without hardcoding platform-specific sizes.

Enums & Unions
Basic enum
# Ruby uses symbols as a lightweight enum substitute DIRECTIONS = %i[north south east west].freeze direction = :north puts direction puts DIRECTIONS.include?(direction) # true
const std = @import("std"); const Direction = enum { north, south, east, west }; pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const direction = Direction.north; try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{direction})); // north // Enums can have methods const opposite = switch (direction) { .north => Direction.south, .south => Direction.north, .east => Direction.west, .west => Direction.east, }; try out.writeAll(try std.fmt.bufPrint(&buf, "{}\n", .{opposite})); // south }

Zig enums are named types with a fixed set of variants, providing stronger guarantees than Ruby's symbol convention. The compiler ensures exhaustive handling in switch expressions β€” if you add a new variant to the enum, every switch that lacks an else clause becomes a compile error. By default, Zig enums are backed by an integer and can be converted with @intFromEnum and @enumFromInt. Enums can also contain methods and declarations, just like structs.

Tagged union
# Ruby uses duck typing β€” no explicit union type shapes = [ { type: :circle, radius: 5.0 }, { type: :rectangle, width: 3.0, height: 4.0 }, ] shapes.each do |shape| area = case shape[:type] when :circle then Math::PI * shape[:radius]**2 when :rectangle then shape[:width] * shape[:height] end puts area.round(2) end
const std = @import("std"); const Rectangle = struct { width: f64, height: f64 }; const Shape = union(enum) { circle: f64, rectangle: Rectangle, }; fn area(shape: Shape) f64 { return switch (shape) { .circle => |radius| std.math.pi * radius * radius, .rectangle => |rect| rect.width * rect.height, }; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const circle = Shape{ .circle = 5.0 }; const rectangle = Shape{ .rectangle = .{ .width = 3.0, .height = 4.0 } }; try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.2}\n", .{area(circle)})); // 78.54 try out.writeAll(try std.fmt.bufPrint(&buf, "{d:.2}\n", .{area(rectangle)})); // 12.00 }

A tagged union (union(enum)) is Zig's equivalent of a type-safe discriminated union or algebraic data type. It stores exactly one of its variants at a time and carries a tag that identifies which variant is active. A switch on a tagged union is exhaustive and captures the inner value with |capture| syntax. This pattern replaces Ruby's duck typing for "value that can be one of several different shapes" β€” where Ruby relies on runtime checks, Zig enforces the variant at compile time and eliminates invalid states entirely.

Gotchas for Rubyists
Integer overflow panics in debug mode
# Ruby integers never overflow β€” they grow automatically big = 2**62 bigger = big * 2 puts bigger # 9223372036854775808 β€” perfectly fine puts bigger.class # Integer
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [32]u8 = undefined; // Wrapping arithmetic: +% does not panic on overflow const maximum: u8 = 255; const wrapped = maximum +% 1; // wraps to 0 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{wrapped})); // 0 // Saturating arithmetic: +| clamps at the maximum const saturated = maximum +| 1; // stays at 255 try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{saturated})); // 255 }

In Debug and ReleaseSafe builds, Zig's default arithmetic operators (+, -, *) panic on integer overflow. This is a deliberate safety feature β€” silent overflow produces subtly wrong results that are hard to debug. When overflow is intentional (e.g., cryptography, hash functions, wrapping counters), use the wrapping operators: +%, -%, *%. For DSP and saturation arithmetic, use the saturating operators +|, -|, *|. In ReleaseFast mode, overflow detection is removed and the behavior is undefined by the language spec (though in practice wraps on most hardware).

No implicit type coercion
whole = 5 ratio = 2.5 puts whole + ratio # 7.5 β€” Ruby promotes int to float puts "Count: " + whole.to_s # must convert explicitly
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [64]u8 = undefined; const whole: i32 = 5; const ratio: f64 = 2.5; // Must convert explicitly — no implicit int→float const sum = @as(f64, @floatFromInt(whole)) + ratio; try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{sum})); // 7.5 // No string concatenation with + either try out.writeAll(try std.fmt.bufPrint(&buf, "Count: {d}\n", .{whole})); }

Zig never coerces types silently. Adding an i32 and an f64 is a compile error β€” you must explicitly convert one type to the other first. There is no + operator for string concatenation (only the comptime ++ for compile-time strings), and no automatic number-to-string conversion. This verbosity is intentional: every type boundary is visible in the source code, making it impossible to accidentally mix units, precision levels, or signed/unsigned values without the compiler catching it.

Optionals must be explicitly unwrapped
value = nil # Ruby: calling a method on nil raises NoMethodError at runtime begin puts value.upcase rescue NoMethodError => error puts "Error: #{error.message}" end # Safe navigation avoids the crash puts value&.upcase || "(nothing)"
const std = @import("std"); pub fn main() !void { const out = std.fs.File.stdout(); var buf: [32]u8 = undefined; var maybe: ?i32 = null; // Compile error: cannot use optional directly as i32 // const doubled = maybe * 2; // ERROR // Must unwrap first if (maybe) |value| { try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{value * 2})); } else { try out.writeAll("(nothing)\n"); } maybe = 21; // .? unwraps and panics if null β€” use when null is a bug try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{maybe.? * 2})); }

In Zig, you cannot use a ?T optional value where a T is expected β€” the compiler rejects it. This eliminates null pointer dereferences at compile time rather than catching them as runtime exceptions. The .? postfix operator (e.g. maybe.?) forcibly unwraps an optional and panics if it is null β€” use it only when null is a programming error, not a normal condition. This is a fundamental improvement over Ruby's nil, where the error only appears at runtime when the wrong method is called.

Unused variables are compile errors
# Ruby silently ignores unused variables result = expensive_calculation rescue 0 another = "never used" puts result def expensive_calculation 42 end
const std = @import("std"); fn expensiveCalculation() i32 { return 42; } pub fn main() !void { const out = std.fs.File.stdout(); var buf: [32]u8 = undefined; const result = expensiveCalculation(); try out.writeAll(try std.fmt.bufPrint(&buf, "{d}\n", .{result})); // Assign to _ to explicitly discard a value _ = expensiveCalculation(); // intentionally unused // Unused variables are a COMPILE ERROR β€” no silent waste // const another = "never used"; // ERROR: unused local constant }

Zig treats an unused local variable as a compile error, not a warning. This catches dead code, forgotten return-value checks, and typos where you meant to use a variable but referenced the wrong name. To intentionally discard a value, assign it to the blank identifier _: _ = someFunction();. This also applies to function parameters β€” if a parameter is not used, you must write _ = parameterName; to silence the error. The same rule applies to unused error return values: calling a function that returns an error without handling it is a compile error.