Skip to content

Error handling

Lesson 8.1

Things go wrong: a value is missing, a player types nonsense, a file is broken. Lua lets you raise errors on purpose, catch them, and clean up after them.

What an error does

When an error happens, Lua stops the current code and goes back up through the function calls until something catches it. If nothing does, the program stops and prints the message:

-- this breaks on purpose
local player = nil
print(player.name)
input:3: attempt to index a nil value (local 'player')

In a Celestial mod, the game catches errors from your event handlers, logs them with the line number, and keeps running. A mod that keeps failing gets turned off for the session, so it is still worth handling errors properly.

Raising an error: error

error(message) raises an error yourself. Use it when your function gets input it can’t work with:

local function set_health(player, value)
  if type(value) ~= "number" then
    error("health must be a number, got " .. type(value))
  end
  player.health = value
end

Lua adds the file and line to the message. The second argument, the level, decides which line that is: 1 (the default) points at the error call, 2 points at the code that called your function, which is usually where the real mistake is. 0 adds no position at all.

local function need_number(x)
  if type(x) ~= "number" then
    error("expected a number", 2)   -- blame the caller
  end
  return x
end

assert

assert(value, message) raises an error when value is false or nil, and otherwise returns its arguments. It is a short way to check something that should always be true:

local function divide(a, b)
  assert(b ~= 0, "can't divide by zero")
  return a / b
end

print(divide(10, 4))  --> 2.5

Many functions return nil plus a message when they fail. assert turns that into an error in one step: local n = assert(tonumber(text), "not a number").

Catching errors: pcall

pcall(f, ...) calls f in protected mode. If f works, pcall returns true and f’s results. If f raises an error, pcall returns false and the error message, and your program keeps going:

local ok, result = pcall(function()
  return 10 // 2
end)
print(ok, result)  --> true	5

local ok2, err = pcall(function()
  error("not enough gold", 0)
end)
print(ok2, err)    --> false	not enough gold

You can pass arguments straight through instead of wrapping in a new function:

local ok, n = pcall(math.floor, 3.7)
print(ok, n)  --> true	3

Error values don’t have to be strings

error accepts any value, including a table. That is handy when the caller needs to know which kind of error happened:

local function buy(gold, price)
  if gold < price then
    error({ code = "too_poor", missing = price - gold })
  end
  return gold - price
end

local ok, err = pcall(buy, 5, 12)
if not ok and err.code == "too_poor" then
  print("You need " .. err.missing .. " more gold.")  --> You need 7 more gold.
end

Lua adds no position to table errors. The Celestial API raises errors like this: they have a code and a message field.

xpcall and tracebacks

xpcall(f, handler, ...) is pcall with a message handler. The handler runs at the moment of the error, before the call stack is unwound, so it can record where the error came from. debug.traceback is the usual handler: it adds the list of calls that led to the error.

local function inner() error("something broke") end
local function outer() inner() end

local ok, trace = xpcall(outer, debug.traceback)
print(ok)     --> false
print(trace)

The trace starts with the message, which already says where the error happened. Below it, stack traceback: lists the calls that led there, newest first: error, then inner, then outer, each with its line.

Expected failures: return nil, message

Not every problem should be an error. For things that are a normal part of using a function, such as “that player isn’t online”, it is often nicer to return nil and a message, the way tonumber and many library functions do:

local players = { alex = { health = 20 } }

local function find_player(name)
  local p = players[name:lower()]
  if not p then
    return nil, "no player called " .. name
  end
  return p
end

local p, why = find_player("Steve")
if not p then
  print(why)  --> no player called Steve
end

A rough rule: raise an error for bugs (wrong argument types, broken state) and return nil, message for situations the caller should handle.

Cleaning up with close

Lua 5.4 has to-be-closed variables. Mark a local with <close> and its value’s __close metamethod runs when the variable goes out of scope, whether the block ends normally, with return, break, or an error:

local function lock(name)
  print("lock " .. name)
  return setmetatable({}, {
    __close = function()
      print("unlock " .. name)
    end,
  })
end

do
  local guard <close> = lock("chest")
  print("using the chest")
end
lock chest
using the chest
unlock chest

It works with pcall too: if the block fails, the value is still closed before the error goes further. The Celestial API uses this for things such as world snapshots that should be released straight away.

Try it

Write parse_amount(text) that turns a string like "16" into an integer between 1 and 64. Raise an error (with level 2) when the text isn’t a number, isn’t whole, or is out of range. Then call it with "16", "abc", "2.5" and "100" inside pcall and print the results.

Show a solution
local function parse_amount(text)
  local n = tonumber(text)
  if not n then
    error("not a number: " .. text, 2)
  end
  n = math.tointeger(n)
  if not n then
    error("must be a whole number: " .. text, 2)
  end
  if n < 1 or n > 64 then
    error("must be between 1 and 64: " .. text, 2)
  end
  return n
end

for _, text in ipairs({ "16", "abc", "2.5", "100" }) do
  print(pcall(parse_amount, text))
end

The first call prints true 16. The others print false and their message. When pcall calls the function directly, there is no calling line to blame, so the messages have no position.

Recap: error and assert raise errors, pcall and xpcall catch them, and <close> variables clean up even when something fails.