luau-expert
Skill1 starsChanged 7 months agoMITView on GitHub
Modern, strictly-typed Luau. Use whenever writing, reviewing, or fixing Luau code (ModuleScripts, ServerScripts, LocalScripts) — language layer only, not Roblox APIs. Covers strict mode, type system, idioms, asserts, modules, and anti-patterns.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- github774/roblox-claude-skillsword for word, first seen Nov 8, 2026
Other versions1 project uses a version of this.
Word for word · 1
- github774/roblox-claude-skills★ 1100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository3 other files it gives its agents.
- roblox-devSkill
- roblox-toolchainSkill
- roblox-uiSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 2 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 11
Write modern, strictly-typed Luau.
- Very specific: line 167
One alphabetical block: No sectioning by package vs local.
- Very specific: line 203
Keep return shape consistent across branches.
- Very specific: line 235
Don't
assert(typeof(x) == ...)to patch types: If your code is strictly typed, that line never trips except via untyped callers — fix the … - Very specific: line 315
When a class is genuinely warranted, see references/typed-class-recipe.md.
- Very specific: line 327
Don't invent syntax: If you're unsure whether a feature (e.g.
- Distinctive: line 328
Stable concepts beat brittle signatures: If you can express the rule as "use the type system to prevent the bug" rather than "this exact …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Luau Language
Write modern, strictly-typed Luau. Source: luau.org.
For deep dives see:
references/type-system.md— primitives, casts, aliases, tables, unions, generics, refinements, type functionsreferences/typed-class-recipe.md— full setmetatable+__indexclass pattern
Further reading: references/type-functions-api.md (analysis-time types library), references/type-patterns.md (branded/recursive types, overloads, read-only tables).
Integration Policy
This skill describes preferred patterns; it does not authorize rewriting existing code. Apply rules to new code and code the user explicitly asks to refactor. Match the codebase's existing style (casing, indentation, quote style, module shape) before applying skill defaults. Don't weaken stricter files; don't introduce tooling the project doesn't already use; surface conflicts once and default to the existing pattern.
Full rules: ../../shared/integration-policy.md.
Principles
- Strictly typed. New files use
--!strict. Migrate legacy code; never weaken a file that was strict. - Bugs caught at analysis time beat runtime asserts. A typed parameter is worth more than
assert(typeof(x) == "number"). - Simple over clever. Small functions, obvious intent.
- Prefer immutability. Mutating shared state under yields breaks everything; with React-style libraries it's non-negotiable.
- Type public APIs fully; skip trivial locals. Annotate anything other modules can call. Don't annotate
local n = item.attack(p)— inference handles it.
Mode Header
--!strict -- default for new files; flags anything that *might* fail at runtime
--!nonstrict -- legacy migration; infers any when type unknown
--!nocheck -- generated/third-party onlyProject-wide: .luaurc with { "languageMode": "strict" }.
Modern Syntax
-- String interpolation (not string.format / not concat)
print(`Player {name} scored {score} points`)
-- if-then-else expression (not `a and b or c`)
local label = if score >= 100 then "Winner" else "Try again"
-- Generalized iteration (not pairs/ipairs)
for k, v in t do ... end
-- Compound assignments
count += 1
path ..= "/suffix"
-- Continue
for _, item in items do
if not item.active then continue end
process(item)
end
-- Floor division
local q = 7 // 2
-- Const bindings (binding immutable; value still mutable)
const MAX_PLAYERS = 16
-- Number separators
local big = 1_000_000
-- Function attributes
@native
local function hotPath(x: number): number
return x * x + x
endipairs only when you specifically need stop-at-first-nil semantics on a sparse array.
Idioms
Early Return / Continue
Guard clauses over pyramids — when the function logically can't keep going, return early.
local function dealDamage(humanoid: Humanoid, damage: number)
if damage <= 0 then return end
humanoid.Health -= damage
endExplicit nil Checks
Most "truthy" checks really mean "not nil". Write what you mean:
if x.Parent ~= nil then ... -- ✅
if x.Parent then ... -- ❌ ambiguousTwo exceptions where truthy is fine: if-expressions (if x then a else b reads better than the inverted form) and or defaults.
or for Defaults; Never x and y or z
soundClone.Volume = volume or 0.5
-- Never simulate ternary with and/or — silently wrong when middle value is falsy
local goldAmount = if gamemode == "arena" then nil else 100 -- ✅
-- local goldAmount = gamemode == "arena" and nil or 100 -- ❌ returns 100 even on arenaDon't Hide Builtins
local insert = table.insert -- ❌
table.insert(items, sword) -- ✅No Call-Sugar
call("string") -- ✅
call({ ... }) -- ✅
call "string" -- ❌
call { ... } -- ❌ (StyLua reformats)Async Suffix on Yielding Functions
local function fetchPlayerDataAsync(userId: number): PlayerData
-- yields here
endReact (and similar render libraries) does not expect yields — a re-render that hits a yielding call silently corrupts component state. Naming yielding functions makes it visible at the call site.
pcall Method-Shorthand
pcall(part.Destroy, part) -- ❌ reads as nonsense
pcall(function() part:Destroy() end) -- ✅
pcall(saveMoney, player, 100) -- ✅ function-shorthand reads naturallyRequires
- No dynamic requires.
require(Modules.X)keeps types;require(child)in a loop erases them. - One alphabetical block. No sectioning by package vs local. Use Luau LSP auto-require + StyLua
sort_requires(covered inroblox-tooling).
Type System (essentials)
-- Optional
type Maybe<T> = T? -- T | nil
-- Tagged union (preferred for results)
type Result<T> = { ok: true, value: T } | { ok: false, err: string }
-- Indexed maps where misses are expected
local playerPoints: { [Player]: number? } = {} -- ✅ checker forces nil-handling
-- { [Player]: number } -- ❌ hides bugs on missing keys
-- Optional argument typing — ? at outer position
local function f(default: (boolean | () -> boolean)?) end -- ✅
-- local function f(default: boolean? | () -> boolean) end -- ❌unknown for untrusted input; narrow before use. any for nothing — always reach for unknown first.
Full tour in references/type-system.md.
nil ≠ Nothing
local function returnsNothing() end -- zero values
local function returnsNil() return nil end -- one value: nil
print(returnsNothing()) -- (blank)
print(returnsNil()) -- nilKeep return shape consistent across branches. If a function returns T?, the no-value branch must return nil, not bare return.
String Enums + exhaustiveMatch
The only enum form to use:
type Color = "red" | "green" | "blue"
local function exhaustiveMatch(value: never): never
error(`Unknown value in exhaustive match: {value}`)
end
local function setColor(color: Color)
if color == "red" then ...
elseif color == "green" then ...
elseif color == "blue" then ...
else exhaustiveMatch(color) -- type error if a branch is missed
end
endNo makeEnum({...}) libraries, no Color = { red = "red" :: Color } wrappers. They give nothing string unions don't.
See examples/exhaustive-match.luau.
Asserts
- Always pass an error message. Bare
assert(cond)produces uselessassertion failed!. - Constant messages only.
assertevaluates its message every call. For formatting useif not cond then error(\...`) end`. - Don't
assert(typeof(x) == ...)to patch types. If your code is strictly typed, that line never trips except via untyped callers — fix the upstream type instead. - At trust boundaries (RemoteEvents, JSON, plugin input) type the input as
unknownand narrow:
HurtMe.OnServerEvent:Connect(function(player, damage: unknown)
if typeof(damage) ~= "number" or damage < 0 then return end
-- damage: number from here
dealDamage(player, damage)
end)Typing the parameter as number makes the runtime check look statically valid even when an exploiter sends a string. unknown forces the check.
See examples/remote-validation.luau.
Modules
--!strict
local OtherModule = require("./OtherModule")
export type Config = { timeout: number, retries: number }
const DEFAULT_CONFIG: Config = { timeout = 30, retries = 3 }
local MyModule = {}
function MyModule.connect(cfg: Config?): ()
local c = cfg or DEFAULT_CONFIG
-- ...
end
return table.freeze(MyModule)- Always assign and
returna named local. Noreturn function() ... end— breaks Ctrl-Shift-F across web/rg. - One file per utility. No
TableUtilmega-modules;flatten.luau,reverse.luauseparately. Luau LSP auto-requires bare names. Project-structure rationale lives inroblox-systems. table.freezethe returned module table to catch accidental mutations.- Cyclic deps: cast the imported module to
anyto break the cycle — and document why.
Shallow Copy, Not Deep
For immutable updates, clone only the path you're touching:
items = table.clone(items)
items[1] = table.clone(items[1])
items[1].durability -= 10Deep copy is always wasted work under immutability — unrelated subtrees keep their identity, which is exactly what == checks across renders want.
See examples/module-pattern.luau.
OOP — Default to Free Functions
Plain data + free functions over classes. Metatables fight the type checker, hurt grep, and surprise readers.
type Slide = { length: number }
type Video = { slides: { Slide } }
-- videoLength.luau
local function videoLength(video: Video): number
local total = 0
for _, slide in video.slides do
total += slide.length
end
return total
end
return videoLengthAside from __index and __tostring (and __mode for the rare weak table), avoid every metamethod. No __call, no __add, no operator overloads — they don't autocomplete and they fight inference.
When a class is genuinely warranted, see references/typed-class-recipe.md.
Verification Rules
- Don't assume Lua APIs exist in Luau. Several were removed or relocated:
setfenv/getfenv— removed.unpack(global) — moved totable.unpack.loadstring— removed; useloadstring-equivalent only vialoadstringif explicitly enabled, otherwise unavailable.bit32— present, but consider native bitwise operators where possible.goto/ labels — not in Luau.
- Don't invent syntax. If you're unsure whether a feature (e.g.
const,@native, type functions, default generics) exists or is stable, check luau.org before using it. State the uncertainty in your reply rather than guessing. - Stable concepts beat brittle signatures. If you can express the rule as "use the type system to prevent the bug" rather than "this exact stdlib function takes these exact arguments", do that — Luau's stdlib evolves; the typing principles don't.
- Roblox APIs are out of scope here. For
RemoteEvent,DataStore,Instance, services, etc., defer to theroblox-devskill. This skill covers the language only.
Anti-Patterns
| ❌ Don't | ✅ Do |
pairs(t) / ipairs(t) | for k, v in t do |
string.format(...) | `interpolated {value}` |
a and b or c | if a then b else c |
if x.Parent then | if x.Parent ~= nil then |
local insert = table.insert | Inline table.insert |
call "x" / call { ... } | call("x") / call({ ... }) |
| Reassignable constants | const MAX = 100 |
| Globals | local / const |
any | unknown + narrowing |
| Trivial local annotations | Let inference work |
| Untyped public function | Annotate params + return |
assert(typeof(x) == "T") to patch types | Fix the upstream type |
assert(cond) no message / formatted message | Constant message; if … then error(…) for formatted |
pcall(part.Destroy, part) | pcall(function() part:Destroy() end) |
{ [K]: V } when keys may be missing | { [K]: V? } |
Bare return from a T? function | Explicit return nil |
for _, m in c:GetChildren() do require(m) | Static require(c.X) |
| Sectioned require blocks | One alphabetical block |
Yielding fn missing Async suffix | Suffix Async |
makeEnum({"red","green"}) | type Color = "red" | "green" + exhaustiveMatch |
__call / __add / operator metamethods | Plain functions |
| Anonymous module return | local foo = ...; return foo |
TableUtil mega-modules | One function per file |
| Deep copy for immutable update | Clone only the touched path |
math.floor(a / b) | a // b |
| Mutating module table | table.freeze the return |
| Pyramid nesting | Early returns + continue |
Examples
examples/good.luau— idiomatic Luau ModuleScript using every recommended pattern.examples/bad.luau— same logic with every anti-pattern. For contrast only.examples/exhaustive-match.luau— string enums +exhaustiveMatchhelper.examples/remote-validation.luau—unknown+ narrowing at a RemoteEvent boundary.examples/module-pattern.luau— clean module skeleton.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

