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

Debugging

Something is not doing what you expected. This chapter is about closing that gap: reading what the runtime tells you, recognising the handful of messages that account for most confusion, and the techniques that turn a vague “it’s broken” into a line number.

Reading an Error

An uncaught error prints three things: what went wrong, where, and how you got there.

Unhandled ValueError: bottomed out
  --> /path/to/main.zu:3

  1 | def recurse(n) {
  2 |   if n <= 0 {
> 3 |     raise ValueError('bottomed out')
  4 |   }
  5 |   recurse(n - 1)

Stack trace (most recent call last):
  at recurse() /path/to/main.zu:3
  at recurse() /path/to/main.zu:5
  ... 19 more frames ...
  at recurse() /path/to/main.zu:5

The type and message come first. Then the source around the failure, with the offending line marked. Then the call stack, innermost first.

A deep stack is truncated in the middle, because the top and the bottom are the parts that tell you anything: the top is where it broke, the bottom is where you started, and two hundred identical recursive frames in between are noise.

The process exits with status 1.

The Messages You Will Actually See

Every message below is real output. Run this and you get all of them:

class Config {

  @new() {
    self.name = 'default'
  }
}

def needs_string(s: string) {
  return s
}

def show(label, work) {
  catch {
    work()
  } as e {
    echo '${label}: ${e.type} — ${e.message}'
  }
}

show('name never declared', @() => undeclared_name)
show('key not in dict', @() { var d = { a: 1 }; return d['b'] })
show('field not on class', @() { var c = Config(); c.nope = 1 })
show('property not on class', @() { var c = Config(); return c.missing })
show('method on nil', @() { var x = nil; return x.f() })
show('operator on nil', @() => nil + 1)
show('index past the end', @() => [1][9])
show('wrong argument type', @() => needs_string(5))
name never declared: UndefinedError — undefined global 'undeclared_name'
key not in dict: PropertyError — undefined key 'b' in dict
field not on class: PropertyError — undefined field 'nope' on instance of 'Config'
property not on class: PropertyError — undefined property 'missing' on instance of 'Config'
method on nil: TypeError — object of type nil does not define method 'f'
operator on nil: TypeError — operator '+' not defined for call signature (nil, number)
index past the end: RangeError — index 9 out of bounds (length 1)
wrong argument type: TypeError — needs_string() expects parameter 's' (argument 1) to be a string, got number

What each one usually means in practice:

MessageWhat to look for
undefined global 'x'a typo, or a def that appears below the top-level line calling it
undefined key 'x' in dicta key that is genuinely absent; get(key, fallback) is the fix when absence is legal
undefined field 'x' on instance of 'C'a typo in a field name — classes are sealed, so this cannot create one
undefined property 'x' on instance of 'C'reading a field or method the class never declared
undefined member 'x' on module mthe module did not export it, or the export needs an @
object of type nil does not define method 'f'something upstream returned nil
operator '+' not defined for call signature (nil, number)the same, one step earlier
'x' is private and can only be accessed via 'self' or 'parent'a leading underscore, reached from outside
'x' is already declared in this scopetwo vars of one name in one block
cannot assign to constant 'x'writing to a local const
module 'x' could not be foundusually a missing leading . on a sibling import

The two nil messages are worth internalising. Zuri never tells you where a nil came from, because by the time it causes trouble the value has already been passed along. When you see one, stop looking at the line that failed and look at whatever produced the value.

Techniques

Echo the Value and Its Type

Dynamic typing means the surprise is almost always that something is not what you assumed it was:

var value = '42'

echo typeof(value)
echo value
echo value + 1
echo value.to_number() + 1
string
42
421
43

One line of typeof() would have saved the third line’s confusion.

Give a Class @to_string()

echo shows an instance through its class’s @to_string(), and as <instance of Point> when the class has none:

class Point {

  @new(x, y) {
    self.x = x
    self.y = y
  }

  @to_string() {
    return '(${self.x}, ${self.y})'
  }
}

var p = Point(1, 2)

echo p
echo [p, Point(3, 4)]
(1, 2)
[(1, 2), (3, 4)]

Define @to_string() on any class you expect to look at while debugging. The five minutes it costs are repaid the first time you print a list of them.

Encode Nested Data Instead of Echoing It

A deep dictionary printed by echo is one unreadable line. json.encode() with compact off indents it:

import json

var request = {
  method: 'POST',
  headers: { accept: 'application/json' },
  body: { title: 'write chapter 19', tags: ['docs', 'zuri'] },
}

echo json.encode(request, false)
{
  "method": "POST",
  "headers": {
    "accept": "application/json"
  },
  "body": {
    "title": "write chapter 19",
    "tags": [
      "docs",
      "zuri"
    ]
  }
}

Add Context on the Way Up

An error raised deep in a call chain says what failed, not what you were doing at the time. Catch it, say what you were doing, and re-raise:

def parse_port(raw) {
  if !raw.match('/^\d+$/') {
    raise ValueError('not a number')
  }

  return raw.to_number()
}

def load_settings(source, raw) {
  catch {
    return { port: parse_port(raw) }
  } as e {
    raise ValueError('while reading ${source}: ${e.message}')
  }
}

catch {
  load_settings('config.json', 'eighty')
} as e {
  echo e.message
}
while reading config.json: not a number

“not a number” is a fact. “while reading config.json: not a number” is a fact you can act on.

The trace is a list on the error, so a handler can log it without letting the program die:

def inner() {
  raise ValueError('deep')
}

def outer() {
  inner()
}

catch {
  outer()
} as e {
  for frame in e.stacktrace {
    echo frame
  }
}
/path/to/main.zu:2 -> inner()
/path/to/main.zu:6 -> outer()
/path/to/main.zu:10 -> @.script()

Ask the Compiler What It Made of Your Code

When an expression does not behave the way you read it, the bytecode settles the argument:

import zuri

echo zuri.compile('var a = -2 ** 2').map(@(i) => i.op)
[LoadConst, LoadConst, Pow, Neg, SetGlobal, LoadNil, Return]

Read the order: the power happens before the negation. ** binds tighter than unary minus, so -2 ** 2 is -(2 ** 2), which is -4, and not the 4 a reader expecting (-2) ** 2 would get. The bytecode settles it in one line. Chapter 21 covers zuri.compile() and zuri.parse() properly.

Narrow It With assert

An assert is a claim you can leave in the code:

def average(numbers) {
  assert !numbers.is_empty(), 'average() needs at least one number'

  return numbers.reduce(@(a, b) => a + b, 0) / numbers.length()
}

echo average([2, 4, 6])

catch {
  average([])
} as e {
  echo '${e.type}: ${e.message}'
}
4
AssertError: average() needs at least one number

Without it, average([]) would have returned NaN and the problem would have surfaced somewhere else entirely, in a value that looks like a number.

Watch the Collector

ZURI_GC_LOG=1 reports garbage collector activity, which is the one to reach for when memory rather than logic is the question:

$ ZURI_GC_LOG=1 zuri run main.zu

Chapter 22 lists the rest of the runtime’s diagnostic switches alongside what each one measures.

The Traps Worth Knowing by Heart

These are the behaviours that produce a wrong answer rather than an error, which makes them far more expensive to find.

Zero is falsy. var n = count or 10 turns a real 0 into 10, and if index is false for the first position and true for -1, “not found”. Compare explicitly.

to_number() returns 0 for text it cannot parse. 'eighty', '' and '12abc' all become 0, and so does ' 7 ' with its spaces. There is no NaN and no error to catch, so a bad input silently becomes a valid zero. Validate the text before converting it.

[] and {} are truthy. if items is true for an empty list. Use is_empty().

sort() mutates; reverse() does not. var s = items.sort() leaves items sorted as well. Clone first when you need both orders.

A method call on a string result was discarded. name.trim() does nothing on its own; strings are immutable, so you must assign the result.

A nested def is local. A function declared inside another is scoped to it, like a var, and nothing outside that scope can call it.

x++ evaluates to the new value. Unlike C and JavaScript.

Structuring Code You Can Reason About

Three habits pay for themselves the first time something breaks.

Separate the decision from the effect. A function that reads a file, parses it and decides something is three functions. Split them, and the parsing and the decision can both be exercised without a filesystem in the way.

Pass dependencies in. A function that calls time() behaves differently every second. One that takes a timestamp behaves identically every time you call it with the same number, which means you can reproduce a failure instead of waiting for it.

Return values instead of printing them. echo inside a function is invisible to its caller and useless to anything that wants to check the result. Return the string and let the caller decide.