Skip to content

Instantly share code, notes, and snippets.

@44100hertz
Last active May 31, 2025 05:53
Show Gist options
  • Save 44100hertz/46ac8cbc7fbda9ed33f73a234a1fcecc to your computer and use it in GitHub Desktop.
Save 44100hertz/46ac8cbc7fbda9ed33f73a234a1fcecc to your computer and use it in GitHub Desktop.
Five Practices for Clean Lua

Five Practices for Clean Lua

Having written tens of thousands of lines of Lua as well as several other langs, I have created guidelines for readable, maintainable, and even portable code.

I'm striving for a sanity that is very rare in the field of programming, that of simplicity. If you're using Lua on purpose, that should be appealing. Even if you're not using Lua, these ideas translate to Javascript and on some level any other language. In fact, following this paradigm makes your code more portable to C, to Rust, and to purely functional languages.

Even if it is not easy to adapt to this paradigm, by using it many headaches disappear. Good code is a narrow path. I can guide you to that path, but you must still walk it.

This article is inspired in part by this Gleam blog post, All you need is data and functions.

1. Minimize global state.

State is defined as any part of the program that changes at runtime. (Lua being interpreted, technically all is runtime. Use discretion; the rule of thumb is that state depends on time or input.)

There should be little global state. By keeping state local, we avoid complex interactions that can quickly spiral out of control. The consequence of this rule may not be clear at first glance.

First, variables in general should be local with minimal to no globals. Global variables should be prefixed by _G, for example _G.getCurrentTime. Configure your linter to enforce this rule, if you haven't already.

However, global variables are not the only global. The require cache of the Lua interpreter is also global, therefore the body of modules is too. All declarations within modules public or not, and of course their fields, become global state in practice if modified.

This therefore limits the singleton module pattern, implying that generally code will consist of immutable modules and local state.

Even in the case where a module acts as a singleton, the recommended pattern is to have the module itself be stateless and to instantiate it as a global externally.

Example: Avoiding singleton modules

Before:

local Counter = {}
local count
function Counter.init()
  count = 0
end
Counter.init()
function Counter.next()
  count = count + 1
  return count - 1
end
return Counter

After:

local Counter = {}
-- do not store the counter in the module itself
function Counter.init()
  return { count = 0 }
end
-- operate only on the passed-in value
function Counter.next(counter)
  counter.count = counter.count + 1
  return counter.count - 1
end
return Counter

2. Minimize shared state.

The temptation when following rule 1 is to replace global state with a god object: a huge table of all state such that every piece of code can modify the state of every other. This is a mistake which recreates the same flawed dynamic.

One of the greatest contributions of the object-oriented technique is the isolation of state. The less state a function touches, the easier it is to reason about it based on the signature. See for example the Law of Demeter (thanks Winny).

For this reason, only the minimum of state should be passed to each function. In any case where a section of state is logically unique, it is encouraged to nest it within a table so that only that inner table is passed into the modifying function. Do not do this excessively and create horrible overly nested structures, but also do not hesitate to do this, allowing oversized tables to be passed around. Be preemptive to avoid refactoring.

Also in keeping with rule 1, each function should generally only modify state passed in. I call this semi-purity; the same is seen in rust with its mutable references.

Example

Before:

local eventList = {}
local event = {
  expired = false,
  effect = "countUp",
  by = 1,
}
local function dispatch (event)
  event.expired = true
  Events.dispatch(eventList, event)
end
dispatch(event)

After:

local eventList = {}
local event = {
  expired = false,
  payload = { effect = "countUp", by = 1 },
}
-- pass events list rather than referencing it via a closure
local function dispatch (eventList, event)
  event.expired = true
  -- dispatch only the payload
  Events.dispatch(eventList, event.payload)
end
dispatch(eventList, event)

A note on Functional Purity

In keeping with functional purity, another means to minimize shared state is to avoid modifying arguments in favor of returning modified values, at least for small data structures. At the cost of excess allocation which may need to be avoided, this allows us to worry less that passed-in values have been modified and makes program state less temporal; less chance of missing a modification that alters program state when re-reading the program, provided that the altered value is assigned to a new local.

Example

Before:

function Point.invert(p)
  p.y = -p.y
end
local point = { x = 5, y = 10 }
Point.invert(point)

After:

function Point.invert(p)
  return { x = p.x, y = -p.y }
end
local point = { x = 5, y = 10 }
local inverted = Point.invert(point)

3. Minimize excess references.

Speaking of references. If you ever wondered how to scream in Lua:

A = { h = true }
A.A = A
assert(A.A.A.A.A.A.A.A.A.h == true)

Jokes aside, another non-solution is storing secondary data references to avoid passing state. (Since every table is a reference, the first reference is the instantiation.) It doesn't matter if they are stored in a field or a closure, the result is the same. By doing this, we lose the ability to cleanly test and patch our functions. Also keep in mind that Lua will not deallocate data until every reference has vanished, and excess references create the potential for memory leaks. Preferring tree-like data structures (in essence the lack of double or circular references) also aids the ability to reflect and serialize data; one of Lua's greatest strengths.

If a function is complex because it operates on a wide variety of data, it will take many arguments. This is life. We can't seek to hide complexity by keeping references around. It is however good to use a table argument to reduce the difficulty of dealing with several positional arguments.

There are cases where secondary references are correct, for example within higher level systems that operate on more basic program data, such as a serializer or a reactive event system.

4. Avoid setmetatable

The one who uses setmetatable but does not understand it has a disease; the one who understands setmetatable yet does not use it has reached the highest attainment. Through fear of this disease, we avoid catching it. - Lua Tzu

It may ruffle feathers to say it, but operator overloading is dangerous.

The highly common pattern of overriding __index to simulate classes via prototypical inheritance, especially paired with the tragic notion of a single constructor, is a habit whose cost arguably outweighs its benefit. Whatever class library you may be using does this exact thing. Do you know how it works?

Using classes, and in general substituting a dot call for a colon call, implies no separation between functions and data within the called table. It stands in the way of testable, obvious code, and is hardly more work than simply specifying the first argument of a function.

There are cases where setmetatable is correct. For example, an imaginary number type or a matrix type can benefit from conventional operators.

However, Lua is not Java, and whatever magic is taking place within a class library is only projecting an illusion on the Lua programmer, allowing one to live in a fantasy which in time serves only as a prison.

Example

Before:

local Queue = {}
-- most people don't code this way, but this is what a class library does.
function Queue.new ()
  return setmetatable({items = {}}, {__index = Queue})
end
function Queue:push (v)
  self.items[#self.items+1] = v
end

After:

local Queue = {}
function Queue.new ()
  return {items = {}}
end
function Queue.push (queue, v)
  queue.items[#queue.items+1] = v
end

5. Use Type Annotations

Lua may be dynamically typed, but most code is based on assumptions about types and breaks when they are unmet. Lua's automatic type conversion is scant -- only between strings and numbers, and incorrect code is likely to error out rather than executing down a wonky path. Instead of relying on these runtime errors, it is better to be preemptive. What if a possible code path, one not tested, has a crash lying in wait?

Though hand-checking code is a healthy practice, it may overrun the author's working memory and leave them hungering for a statically typed language.

To have an easier time with Lua, we may turn to the excellent Lua Language Server. However, without the use of LuaCATS annotations, the LSP is loose about types and misses many errors. As programs grow in complexity, the author will miss them too.

Not only does type annotation catch errors early, it is also documentation. An unfamiliar programmer, such as ones future self, need not read a function's body to know its parameter types. The use of type aliases such as @alias Id number also clarify the meaning of a name, where for example an ambiguous target: number becomes clearer as target: Id.

Until the promising Luau Lang may offset the use of Lua, these annotations are a small effort which reduces the great effort of maintaining a program. It brings to mind the words robust and scalable, which are hardly associated with Lua, normally.

Patience is required. The LSP may be unable to verify the logic of a correct-seeming program and throw an error. One may find resolving these errors to be more effort than writing the types themselves, or that in some cases they reveal subtle logical faults in the program -- whether to bodge types or to rewrite logic requires careful reading.

Though a well-typed program has no guarantee of being correct, by eliminating a large class of errors it is easier to focus on the essential logic of the program.

Conclusion

Ultimately, the only rule to follow is that code should be easy to reason about; not just for you, but provably so in general. All of these rules are in service of that one, and if you break a rule in service of simplicity, that is good. If you break a rule in service of habit at the cost of simplicity, that is lazy.

You may follow all of these rules and preach about them, yet still write bad code. If your code is ignoring all of these guidelines, it is unlikely to be good code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment