Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11: Nil Tracking and Safety

Audience: All
Time: 120 minutes
Prerequisites: 02-Values, 05-Control-Flow, 07-Classes
You'll learn: Nullable types, nil checking, type narrowing, the ! force-unwrap operator, optionals


The Big Picture

Nil (null/nothing) is one of the most common sources of bugs. Zebra's nil tracking forces you to handle it:

"A billion-dollar mistake." — Tony Hoare, on inventing the null reference

Zebra says: Explicit nil is safe. Implicit nil is forbidden.


Nullable Types

Marking Optionality

# file: 11_nullable.zbr
# teaches: nullable types
# chapter: 11-Nil-Tracking-and-Safety

class User
    var name: str      # Can't be nil
    var nickname: str? # Can be nil
    var bio: str?      # Can be nil

def main()
    var user = User()
    user.name = "Alice"    # ✅ Fine
    user.nickname = nil    # ✅ Allowed (it's str?)
    user.bio = "Developer" # ✅ Can assign string to str?
    
    # This won't compile:
    # var empty: str = nil  # ❌ str can't be nil

Key point: The ? mark means "this can be nil or the type."


Nil Checking

Safe Access Pattern

# file: 11_nil_check.zbr
# teaches: nil checking
# chapter: 11-Nil-Tracking-and-Safety

def main()
    var nickname: str? = "Bobby"
    var empty: str? = nil
    
    # Check before using
    if nickname != nil
        print(nickname)  # Safe: known to not be nil
    
    if empty == nil
        print("No nickname set")
    else
        print(empty)
    
    # Two ways to check:
    if nickname != nil
        print("Has nickname")
    
    if nickname == nil
        print("No nickname")
    else
        print("Has: ${nickname}")

Type Narrowing

Type Narrowing Flow

After checking, the type is narrowed:

# file: 11_narrowing.zbr
# teaches: type narrowing
# chapter: 11-Nil-Tracking-and-Safety

def process_name(input: str?)
    if input != nil
        # Inside the block, input is narrowed to str
        print(input.len)  # ✅ Safe: know it's str
        print(input.upper())  # ✅ Safe

Narrowing follows the if x != nil block, not an early return. The early-return shape — if input == nil: return and then using input below — does not narrow (the else-branch of == nil is on the not-yet list), so write it as the block above, or bind with if input as name (next section), or unwrap explicitly with input!.

# an early return still works, with an explicit unwrap after it
def shout(input: str?): str
    if input == nil
        return ""
    return input!.upper()

if x as n — Unwrap-and-Bind

The most idiomatic way to unwrap an optional and use the inner value is the if x as n binding form. When x: T?, it binds a non-optional n: T in the then-branch — no separate unwrap required:

# file: 11_if_as.zbr
# teaches: optional unwrap binding form
# chapter: 11-Nil-Tracking-and-Safety

def main()
    var maybe_name: str? = "Alice"

    if maybe_name as name
        # `name` is `str` here — non-optional
        print("Hello, ${name}!")
    else
        print("No name")

This form combines the nil-check, the unwrap, and the binding into a single statement, and it works both for plain optionals (T?) and for optional class/union check + unwrap together:

# Combined type check + unwrap:
var maybe_user: User? = lookup()
if maybe_user is User as u
    print(u.name)  # u is `User` (non-optional)

if x as n is preferred over ! whenever the wrapped value is going to be used right away — it's safer (no panic possibility) and reads more naturally.


The ! Operator (Force-Unwrap)

Warning: Only use when you're absolutely certain the value isn't nil.

# file: 11_unwrap.zbr
# teaches: force-unwrap operator
# chapter: 11-Nil-Tracking-and-Safety

def main()
    var name: str? = "Alice"
    
    # Unwrap: assert it's not nil
    var safe_name = name!
    print(safe_name)  # Now just str
    
    # If it WAS nil, this would crash
    var empty: str? = nil
    # var crash = empty!  # ❌ Would panic at runtime

Unwrap with Fallback

# file: 11_unwrap_or.zbr
# teaches: safe unwrapping
# chapter: 11-Nil-Tracking-and-Safety

def get_user_name(user_id: int): str?
    if user_id == 1
        return "Alice"
    return nil

def main()
    var maybe_name = get_user_name(1)

    # Option 1: Check and use default
    if maybe_name as name
        print(name)
    else
        print("Unknown user")

    # Option 2: `orelse` supplies a fallback when the value is nil
    var safe_name = get_user_name(2) orelse "Guest"
    print(safe_name)  # Guest

Real World: Database Queries

# file: 11_database.zbr
# teaches: nil in realistic scenarios
# chapter: 11-Nil-Tracking-and-Safety

class User
    var id: int
    var name: str
    var email: str?

class UserDatabase
    static
        def find_user(user_id: int): User?
            if user_id == 1
                var user = User()
                user.id = 1
                user.name = "Alice"
                user.email = "alice@example.com"
                return user
            return nil
        
        def find_user_email(user_id: int): str?
            var user = find_user(user_id)
            if user == nil
                return nil
            return user.email

def main()
    var user = UserDatabase.find_user(1)
    if user != nil
        print(user.name)
        if user.email != nil
            print(user.email)
        else
            print("No email on file")
    else
        print("User not found")
    
    # Chaining nil checks
    var email = UserDatabase.find_user_email(999)
    if email != nil
        print("Email: ${email}")
    else
        print("User not found")

Common Patterns

Optional Chaining

def get_user_city(user_id: int): str?
    var user = find_user(user_id)
    if user == nil
        return nil
    var address = get_address(user.id)
    if address == nil
        return nil
    return address.city

Guard Clauses

def process(data: str?)
    if data == nil
        return
    if data.len == 0
        return
    # Now process safely
    do_work(data)

If you're new to programming

Nil means "nothing" or "no value." Different languages call it null, None, undefined.

Zebra makes you declare which variables can be nil with ?. This prevents accidental crashes.

Type narrowing means the compiler recognizes that after you check if x != nil, you can safely use x.


Common Mistakes

❌ Mistake: Forgetting to check for nil

var email: str? = get_email()
print(email.len)  # ❌ Crash if email is nil!

✅ Better:

var email: str? = get_email()
if email != nil
    print(email.len)  # ✅ Safe

❌ Mistake: Using ! without certainty

var value: str? = get_value()
print(value!)  # ❌ Crashes if value is nil

✅ Better:

var value: str? = get_value()
if value != nil
    print(value)  # ✅ Safe — or use a fallback: print(value orelse "none")

❌ Mistake: Assigning nil to non-nullable

var name: str = nil  # ❌ str can't be nil

✅ Better:

var name: str? = nil  # ✅ Declares it can be nil

Exercises

Exercise 1: Find User Email

Write a function that safely retrieves a user's email:

Solution
class User
    var id: int
    var name: str
    var email: str?

class UserDB
    static
        def find_user(id: int): User?
            if id == 1
                var user = User()
                user.id = 1
                user.name = "Alice"
                user.email = "alice@example.com"
                return user
            return nil
        
        def get_email(user_id: int): str?
            var user = find_user(user_id)
            if user == nil
                return nil
            return user.email

def main()
    var email = UserDB.get_email(1)
    if email != nil
        print("Email: ${email}")
    else
        print("User not found or no email")

Exercise 2: Safe Division

Write a division function that returns nil on error:

Solution
class Calculator
    static
        def safe_divide(a: float, b: float): float?
            if b == 0.0
                return nil
            return a / b

def main()
    var result = Calculator.safe_divide(10.0, 2.0)
    if result != nil
        print("Result: ${result}")
    
    var bad = Calculator.safe_divide(10.0, 0.0)
    if bad != nil
        print(bad)
    else
        print("Cannot divide by zero")

Exercise 3: Nullable Chain

Write a function that navigates nullable fields:

Solution
class Profile
    var name: str
    var bio: str?

class User
    var id: int
    var profile: Profile?

class UserService
    static
        def get_user_bio(user_id: int): str?
            var user = find_user(user_id)
            if user == nil
                return nil
            var profile = user.profile
            if profile == nil
                return nil
            return profile.bio
        
        def find_user(id: int): User?
            if id == 1
                var user = User()
                user.id = 1
                var profile = Profile()
                profile.name = "Alice"
                profile.bio = "Developer"
                user.profile = profile
                return user
            return nil

def main()
    var bio = UserService.get_user_bio(1)
    if bio != nil
        print("Bio: ${bio}")
    else
        print("No bio found")

Next Steps

  • → 12-Error-Handling — throws, raise, and catch for error cases
  • → 14-Contracts — Enforce non-nil invariants
  • 🏋 Project-2-HTTP-Server — Handle nil API responses

Key Takeaways

  • ? marks nullable types — str? can be string or nil
  • Check before using — if value != nil { ... }
  • Type narrowing — Compiler recognizes after checks
  • ! unwraps — Only when certain it's not nil
  • Guard clauses — Exit early if nil
  • Nil safety prevents crashes — It's a feature, not a limitation

Next: Head to 12-Error-Handling for exceptions (throws/raise/catch) and error propagation.