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.
# 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.
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.
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.
# 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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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(); }.
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.
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.
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.
# 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.
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 };.
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.
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.
# 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.
# 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.
# 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.
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.
# 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.
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.
# 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.
# 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.
# 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).
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.
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.
# 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.