Skip to main content

Luau Conventions

This guide covers how to write Luau code in this project. All code uses --!strict mode, and every file follows consistent patterns for class structure, typing, and naming. These conventions are shared with the Raven companion repo.

Why strict typing?

Luau's type inference doesn't handle metatables well. When you write setmetatable({}, MyClass), Luau can't automatically infer the fields you'll assign to self in the constructor. Without explicit type annotations, the type checker either flags legitimate code or loses track of types entirely.

The project uses an explicit typing pattern that tells the type checker exactly what fields exist on each class. This is more verbose than untyped Lua, but it catches real bugs — misspelled field names, wrong argument types, missing nil checks — at edit time instead of at runtime in a live game.

Class structure

Every class follows this structure:

1. File header and requires

--!strict
--[=[
@class MyClass
]=]

local require = require(script.Parent.loader).load(script)

-- Requires are auto-sorted by stylua
local BaseObject = require("BaseObject")
local ServiceBag = require("ServiceBag")

The require(script.Parent.loader).load(script) line enables the custom module resolution system. Every file needs it.

2. Class table setup

local MyClass = setmetatable({}, BaseObject)
MyClass.ClassName = "MyClass"
MyClass.__index = MyClass

ClassName is a static field used for debugging and identification. Always matches the class name.

3. Export type declaration

Place this after the class table setup, before the constructor. It tells the type checker what fields exist on instances of this class:

export type MyClass =
typeof(setmetatable(
{} :: {
_obj: Instance,
_serviceBag: ServiceBag.ServiceBag,
_enabled: ValueObject.ValueObject<boolean>,
-- list ALL instance fields with their types
},
{} :: typeof({ __index = MyClass })
))
& BaseObject.BaseObject -- intersection with parent type

The & ParentClass.ParentClass intersection gives you access to inherited fields like _maid and _obj.

4. Constructor

function MyClass.new(obj: Instance, serviceBag: ServiceBag.ServiceBag): MyClass
local self: MyClass = setmetatable(BaseObject.new(obj) :: any, MyClass)

self._serviceBag = assert(serviceBag, "No serviceBag")
-- Initialize fields...

return self
end

The :: any cast on setmetatable is necessary because Luau can't verify the metatable transformation preserves the type. This is one of the few places :: any is acceptable.

5. Methods — use dot syntax

Strict mode requires explicit self typing. Use dot syntax (not colon syntax) for method definitions:

-- Correct: dot syntax with explicit self type
function MyClass.GetEnabled(self: MyClass): boolean
return self._enabled.Value
end

-- Wrong: colon syntax loses self type in strict mode
function MyClass:GetEnabled(): boolean
return self._enabled.Value -- type error: _enabled not known
end

Callers still use colon syntax (myObj:GetEnabled()). Only the definition changes.

6. Binder return

When a class is bound to Roblox instances via a tag:

return Binder.new("MyTag", MyClass :: any) :: Binder.Binder<MyClass>

The :: any on the class and the :: Binder.Binder<MyClass> on the return give the binder system proper generic typing.

Common type imports

These are the types you'll use most often:

TypePackageDescription
ServiceBag.ServiceBagServiceBagDependency injection container
Observable.Observable<T>RxReactive observable stream
Brio.Brio<T>BrioValue with lifecycle (value + cleanup)
Maid.MaidMaidResource cleanup tracker
ValueObject.ValueObject<T>ValueObjectReactive value container
Signal.Signal<T>SignalEvent signal
BaseObject.BaseObjectBaseObjectBase class type
Binder.Binder<T>BinderTag-based instance binder
AttributeValue.AttributeValue<T>AttributeValueAttribute-backed reactive value

Naming conventions

  • Private fields: _ prefix (self._maid, self._enabled, self._processAsync)
  • Public signals: PascalCase (self.HumanoidEntered, self.PlayerCount)
  • Methods: PascalCase (GetEnabled, ObservePlayersBrio)
  • Observable methods: Observe* prefix, often with Brio suffix (ObservePlayersBrio)
  • ClassName: Always matches the class name exactly

Coding conventions

  • Require pattern: local require = require(script.Parent.loader).load(script) at the top of every file
  • Requires sorted: stylua sorts requires alphabetically ([sort_requires] enabled = true)
  • Assert serviceBag: self._serviceBag = assert(serviceBag, "No serviceBag") in constructors
  • Moonwave docstrings: --[=[ @class ClassName ]=] at the top of each file
  • Self-documenting code: For private members, prefer self documenting code over lots of of comments. If you must document private things, use --[[ ]], not --[=[ ]=], as moonwave only parses --[=[ ]=] blocks.
  • Conventional commits: feat(scope):, fix(scope):, chore(scope):, etc.
  • Commit messages describe impact, not reasoning: Keep them short. e.g. fix(localizedtextutils): make translationArgs optional
  • Squash before pushing: Rebase and squash into a single cohesive commit before pushing

When to use :: any casts

:: any is a last resort. Acceptable uses:

  • setmetatable(ParentClass.new(obj) :: any, MyClass) — metatable transformation in constructors
  • Binder.new("Tag", MyClass :: any) — binder registration
  • Rx Pipe chains where intermediate types can't be inferred
  • Signal.new() :: any — when the signal type would be too complex to annotate inline

Prefer fixing upstream types over casting. If a type is wrong, fix it in the source package.

"Code is too complex to typecheck"

The old solver has a per-module complexity budget, and a class that both inherits from a base class and names another class's type can exceed it. The error is reported at (1,1) with no useful location, and it masks every other error in the file — clearing it usually reveals real ones underneath, so never treat it as the only problem.

It is cumulative, not caused by one construct, so hunt for the cheapest thing to give up rather than the "wrong" one. The cost is concentrated in typeof(setmetatable(...)) class types: a single mention of one imported from another module can be enough to tip a file over.

Two remedies, both in use:

  • Inherit at runtime only. Instead of local MyClass = setmetatable({}, BaseClass), write setmetatable(MyClass :: any, BaseClass) and supply the inherited surface structurally as a BaseClassLike record you intersect instead. See HasSaveSlotsDataStore and PlayerSettings.
  • Name a minimal structural type for a collaborator rather than its real one — a FooLike record listing only the members you call. See PlayerSettings' SettingsDataServiceLike.

Where even one mention is too many, typing the field any at that single boundary is acceptable — comment it with the reason, and keep the surrounding methods' own return types precise, since those are what callers read. HasSaveSlots.GetSlotsDataStore is the worked example.

Measure, don't guess: change one annotation, re-run npm run lint:luau, repeat. And run it through npm run — invoking luau-lsp analyze by hand without the script's flags produces hundreds of bogus "Unknown require: loader" / "Unknown type" errors that look like real breakage.