Zig Optional Types

An optional type represents a value that might exist or might not. Every programming problem eventually involves "no result" scenarios — a search that finds nothing, a setting that has not been configured, a user input that was not provided. Zig models these situations with optional types, which force you to handle the "no value" case before accessing the data.

The Optional Concept

  Regular type:          Optional type:
  +---------+            +-------------------+
  |  i32    |            |  ?i32             |
  | Always  |            | May have a value  |
  | has a   |            | or may be null    |
  | value   |            +-------------------+
  +---------+                |         |
    x = 42                   |         |
                          has value  is null
                          (some 42) (nothing)

A question mark before the type makes it optional. i32 always holds an integer. ?i32 holds either an integer or null.

Declaring Optional Variables

var score: ?i32 = null;       // no score yet
score = 88;                    // now it has a value
score = null;                  // back to no value
const name: ?[]const u8 = null;          // no name
const found: ?usize = 42;               // index found
const result: ?f64 = compute_value();   // depends on function

Checking and Unwrapping an Optional

Using if-capture

const maybe_age: ?u8 = 25;

if (maybe_age) |age| {
    std.debug.print("Age is {d}\n", .{age});
} else {
    std.debug.print("Age unknown\n", .{});
}
  maybe_age = ?u8
       |
  Has a value?
       |
   yes → capture age = 25 → print "Age is 25"
   no  → print "Age unknown"

Using orelse — Default Value

The orelse operator provides a fallback value when the optional is null:

const discount: ?u32 = null;
const applied = discount orelse 0;
std.debug.print("Discount: {d}%\n", .{applied});  // 0
  discount = null
       |
  orelse 0
       |
  applied = 0
const saved_name: ?[]const u8 = "Pradeep";
const display = saved_name orelse "Guest";
std.debug.print("Hello, {s}!\n", .{display});  // Hello, Pradeep!

Using orelse with a Block

The orelse block can execute multiple statements before returning the fallback:

const value = expensive_lookup() orelse blk: {
    std.debug.print("Computing default...\n", .{});
    break :blk 42;
};

Forceful Unwrap — Use With Caution

const x: ?i32 = 100;
const n = x.?;  // panic if x is null!
std.debug.print("{d}\n", .{n});  // 100

The .? suffix forces the optional open. If the value is null, the program panics at runtime. Use this only when you are absolutely certain the value exists — prefer the if-capture pattern in most situations.

While Loop with Optionals

A while loop can iterate as long as an optional has a value:

fn nextItem(idx: *usize) ?u32 {
    const data = [_]u32{ 10, 30, 50, 70 };
    if (idx.* >= data.len) return null;
    const val = data[idx.*];
    idx.* += 1;
    return val;
}

var i: usize = 0;
while (nextItem(&i)) |item| {
    std.debug.print("Item: {d}\n", .{item});
}
  nextItem returns:
  → 10   (i=0)
  → 30   (i=1)
  → 50   (i=2)
  → 70   (i=3)
  → null (i=4) → loop ends

  Output:
  Item: 10
  Item: 30
  Item: 50
  Item: 70

Optional Pointers

  Regular pointer: *i32    → MUST point somewhere valid
  Optional pointer: ?*i32  → MAY be null

  In C:
  int *p = NULL;  ← no type protection. Can dereference accidentally.

  In Zig:
  var p: ?*i32 = null;  ← type is explicit.
  p.*;    ← COMPILE ERROR. Must unwrap first.

  if (p) |ptr| {
      ptr.*;  ← safe dereference
  }

Optional pointers eliminate null dereference bugs at the type level. In Zig, you cannot accidentally dereference a possibly-null pointer — the compiler requires you to check first.

Chaining Optionals with orelse

fn findUser(id: u32) ?[]const u8 {
    if (id == 1) return "Alice";
    return null;
}

fn getUserCity(name: []const u8) ?[]const u8 {
    if (std.mem.eql(u8, name, "Alice")) return "Mumbai";
    return null;
}

const user_id: u32 = 1;
const city = blk: {
    const name = findUser(user_id) orelse break :blk "Unknown city";
    break :blk getUserCity(name) orelse "City not found";
};
std.debug.print("City: {s}\n", .{city});

Optional in Structs

const UserProfile = struct {
    username: []const u8,
    email: []const u8,
    phone: ?[]const u8 = null,   // phone is optional
    bio:   ?[]const u8 = null,   // bio is optional
};

const user = UserProfile{
    .username = "zigfan99",
    .email    = "fan@example.com",
    .phone    = "9876543210",
    // bio left as null
};

if (user.bio) |bio| {
    std.debug.print("Bio: {s}\n", .{bio});
} else {
    std.debug.print("No bio provided.\n", .{});
}

Optional vs Error Union

  Use optional (?T) when:        Use error union (!T) when:
  +------------------------+     +---------------------------+
  | "No result" is normal  |     | Failure needs explanation |
  | Search returns nothing |     | File read fails           |
  | Setting not configured |     | Network error occurred    |
  | User typed nothing     |     | Invalid input format      |
  +------------------------+     +---------------------------+

  ?[]const u8  → found the name or didn't find it
  ![]const u8  → read the file or failed with an error message

Choose optional when absence of a value is a normal, expected outcome. Choose an error union when something went wrong and you need to communicate why. Mixing these up leads to code that either swallows errors silently or over-engineers simple lookups.

Leave a Comment

Your email address will not be published. Required fields are marked *