Back

Activity 1.12: Modules and Handling Errors

divider

The Idea

One file is fine until it is four hundred lines. Lua's answer is small and, by now, predictable: a module is a table that a file returns. There is no module keyword and no export list.

bay.lua
-- bay.lua - a module is just a table you return
local bay = {}
bay.capacity = 6
local docked = 0
function bay.dock(count)
docked = math.min(bay.capacity, docked + count)
return docked
end
function bay.status()
return docked .. " of " .. bay.capacity
end
return bay

Every idea in that file is one you already have. A table (1.10). Functions stored in it (1.7). A local captured by those functions, which makes it private (1.8). And the last line hands the table to whoever asked.

main.lua
local bay = require("bay")
print(bay.status())
Output
0 of 6

require("bay") finds bay.lua next to your file, runs it once, and gives you what it returned. Note the name has no .lua on it.

"Runs it once" is the part worth remembering. Require the same module from five files and the file executes a single time; everyone gets the same table, and so everyone shares its state. That is usually what you want and occasionally a surprise.

The variable name on your side is yours to choose — local dock = require('bay') works just as well. The module has no say in what you call it, which is a nice consequence of it being an ordinary value.

When something is actually wrong

Almost nothing in Lua stops your program. A missing variable is nil, a missing table key is nil, a wrong argument count is tolerated. You have been shown this repeatedly, and it is why Lua bugs tend to surface far away from their cause.

error is how you stop on purpose, and pcall — protected call — is how you run something that might fail without going down with it.

main.lua
local ok, err = pcall(function()
error("reactor offline", 0)
end)
print(ok, err)
Output
false reactor offline

pcall always returns at least two things: whether it survived, and then either the results or the error message. It is the multiple returns from Activity 1.7 doing something genuinely load-bearing.

The 0 in error("...", 0) means do not attach a file and line number. Leave it out and you get main.lua:2: reactor offline, which is usually more useful — you will see both forms in the checkpoint.

Checking your assumptions

assert is error with the condition built in: carry on if the value is truthy, stop with your message if it is not.

main.lua
local function readCharge(source)
assert(type(source) == "table", "expected a table")
return source.charge or 0
end
print(readCharge({ charge = 98 }))
print(readCharge({}))
Output
98
0

Use it at the top of a function, on the arguments. That is where an assert earns its keep — it moves the failure from wherever the bad value eventually caused trouble to the moment it arrived, which is the difference between a two-minute bug and an hour.

Remember Activity 1.5 while you write them. assert(count) passes when count is 0, because zero is truthy — that is the check you wanted. But it also passes for a string, a table, or anything else that is not nil, so assert what you actually mean: assert(type(count) == "number").

Do not reach for pcall everywhere. Wrapping your whole program in one turns a crash with a line number into silence, which is worse. Use it where failure is expected and recoverable — reading a save file that might not exist, loading an image that might be missing — and let genuine bugs crash loudly while you can still see them.

divider

Build

This activity needs two files in the same folder. Create bay.lua with the module shown above, then start a fresh main.lua beside it.


Task 1: Use your module

  • Require the bay module and print its capacity.
  • Dock 2 ships and report the status.
  • Then dock 9 more and report again. The clamp inside the module should hold it at capacity.
  • Finally, print bay.docked from outside. It is nil — the counter is a local inside the module, so nothing out here can reach it. That is a closure doing exactly what Activity 1.8 said it would.
main.lua
-- main.lua - Skynest bay controller
local bay = require("bay")
print("Capacity:", bay.capacity)
bay.dock(2)
print("Status:", bay.status())
bay.dock(9)
print("Status:", bay.status())
print("Hidden counter:", bay.docked)
Output
Capacity: 6
Status: 2 of 6
Status: 6 of 6
Hidden counter: nil

Task 2: Guard a function's input

  • Write readCharge(source) that asserts its argument is a table, then returns source.charge or 0.
  • Call it once with a charge and once with an empty table.
  • Both should succeed. The assert is not doing anything visible yet, which is the normal state of a good assert.
main.lua
-- main.lua - Skynest bay controller
local bay = require("bay")
print("Capacity:", bay.capacity)
bay.dock(2)
print("Status:", bay.status())
bay.dock(9)
print("Status:", bay.status())
print("Hidden counter:", bay.docked)
local function readCharge(source)
assert(type(source) == "table", "expected a table")
return source.charge or 0
end
print("Charge:", readCharge({ charge = 98 }))
print("Charge:", readCharge({}))
Output
Capacity: 6
Status: 2 of 6
Status: 6 of 6
Hidden counter: nil
Charge: 98
Charge: 0

Task 3: Survive a failure

  • Call readCharge with a string through pcall, and print both values it returns.
  • Then raise your own error inside a pcall, this time with 0 as the second argument.
  • Compare the two messages. One carries a file and line number and one does not, and that is the only difference the 0 made.
  • The program should reach the end and exit normally. Nothing crashed, and you handled two failures.
main.lua
-- main.lua - Skynest bay controller
local bay = require("bay")
print("Capacity:", bay.capacity)
bay.dock(2)
print("Status:", bay.status())
bay.dock(9)
print("Status:", bay.status())
print("Hidden counter:", bay.docked)
local function readCharge(source)
assert(type(source) == "table", "expected a table")
return source.charge or 0
end
print("Charge:", readCharge({ charge = 98 }))
print("Charge:", readCharge({}))
local ok, err = pcall(readCharge, "not a table")
print("Survived:", ok, err)
local ok2, err2 = pcall(function()
error("reactor offline", 0)
end)
print("Survived:", ok2, err2)

Challenge (Optional)

  • Add an undock(count) function to the module that refuses to go below zero, and give it its own assert.
  • Move the Ship class from Activity 1.11 into a ship.lua module and require it. This is exactly how Unit 2 is organized, so it is worth doing once now.
  • Require the bay module twice into two different variables, dock through one, and read the status through the other. Predict what you will see before running it — the answer follows from "runs it once."
divider

Check Your Work

Your finished file should produce this.

Output
Capacity: 6
Status: 2 of 6
Status: 6 of 6
Hidden counter: nil
Charge: 98
Charge: 0
Survived: false main.lua:16: expected a table
Survived: false reactor offline

That is Unit 1. You have the whole language: five types, three loops, functions that return several things, closures, one data structure doing five jobs, and a way to split a program across files. Nothing has been held back for later.

Unit 2 adds a window, a game loop, and a way to draw. It adds no new Lua. Every enemy will be an instance from Activity 1.11, every list of bullets will be walked backwards as in Activity 1.9, and every timer will be a closure from Activity 1.8.