关于 luau-expert
面向 Luau 开发者的现代严格类型编程技能,介绍类型、语法、模块和验证实践,并提供示例;强调遵循现有项目约定,不擅自改写代码。
主要功能
- 01严格类型与类型系统指南
- 02现代 Luau 语法与惯用写法
- 03模块与不可变性模式
- 04信任边界输入验证
- 05反模式与验证规则
- 06参考指南与代码示例
适用场景
- 01编写新的严格类型 Luau 文件
- 02在明确要求时重构代码
- 03检查 Luau 类型与 API 边界
- 04采用符合项目风格的语言模式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ afrxo/ roblox-agent-skills --skill luau-expert安装后在 Claude Code 中输入/luau-expert确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/.gitkeepbad.luauexhaustive-match.luaugood.luaumodule-pattern.luauremote-validation.luaureferences/type-functions-api.mdtype-patterns.mdtype-system.mdtyped-class-recipe.md详细介绍
Luau 语言
编写现代、严格类型化的 Luau。来源:luau.org。
深入了解请参阅:
references/type-system.md— 基本类型、类型转换、别名、表、联合类型、泛型、细化、类型函数references/typed-class-recipe.md— 完整的 setmetatable+__index类模式
延伸阅读:references/type-functions-api.md(分析时使用的 types 库)、references/type-patterns.md(品牌类型/递归类型、重载、只读表)。
集成策略
此技能介绍推荐模式;它不授权重写现有代码。将规则应用于新代码,以及用户明确要求重构的代码。应用技能默认设置前,先遵循代码库现有的风格(大小写、缩进、引号风格、模块结构)。不要降低更严格文件的要求;不要引入项目尚未使用的工具;遇到冲突时指出一次,并以现有模式为准。
完整规则:../../shared/integration-policy.md。
原则
- 严格类型化。 新文件使用
--!strict。迁移旧代码;绝不降低严格文件的要求。 - 在分析时捕获错误,胜过运行时断言。 类型化参数比
assert(typeof(x) == "number")更有价值。 - 简单胜过巧妙。 使用小函数,意图清晰。
- 优先保证不可变性。 在 yield 期间修改共享状态会导致一切出错;对于 React 风格的库,这一点不可妥协。
- 为公共 API 完整标注类型;略过简单的局部变量。 为其他模块可调用的内容添加注解。不要为
local n = item.attack(p)添加注解——类型推断会处理。
模式标头
--!strict -- default for new files; flags anything that *might* fail at runtime
--!nonstrict -- legacy migration; infers any when type unknown
--!nocheck -- generated/third-party only
项目范围:使用包含 { "languageMode": "strict" } 的 .luaurc。
现代语法
-- 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
end
仅当稀疏数组需要遇到第一个 nil 时停止的语义时,才使用 ipairs。
惯用写法
提前返回/继续
使用守卫子句,避免层层嵌套——当函数在逻辑上无法继续时,提前返回。
local function dealDamage(humanoid: Humanoid, damage: number)
if damage <= 0 then return end
humanoid.Health -= damage
end
显式检查 nil
大多数“真值”检查实际表示“不是 nil”。按真实意图编写:
if x.Parent ~= nil then ... -- ✅
if x.Parent then ... -- ❌ ambiguous
有两个例外,可以使用真值检查:if 表达式(if x then a else b 比反转形式更易读)和 or 默认值。
使用 or 提供默认值;绝不使用 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 arena
不要隐藏内置项
local insert = table.insert -- ❌
table.insert(items, sword) -- ✅
不使用调用语法糖
call("string") -- ✅
call({ ... }) -- ✅
call "string" -- ❌
call { ... } -- ❌ (StyLua reformats)
会 yield 的函数使用 Async 后缀
local function fetchPlayerDataAsync(userId: number): PlayerData
-- yields here
end
React(及类似的渲染库)不应遇到 yield——重新渲染时若调用了会 yield 的函数,会悄然破坏组件状态。为会 yield 的函数命名,可以让这一点在调用处一目了然。
pcall 方法简写
pcall(part.Destroy, part) -- ❌ reads as nonsense
pcall(function() part:Destroy() end) -- ✅
pcall(saveMoney, player, 100) -- ✅ function-shorthand reads naturally
Requires
- 不使用动态 require。
require(Modules.X)保留类型;在循环中使用require(child)会抹除类型。 - 统一按字母顺序排列。 不按包和本地模块分段。使用 Luau LSP 自动 require 和 StyLua
sort_requires(见roblox-tooling)。
类型系统(要点)
-- 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;使用前先缩窄类型。不要使用 any——始终优先考虑 unknown。
完整介绍见 references/type-system.md。
nil ≠ 无返回值
local function returnsNothing() end -- zero values
local function returnsNil() return nil end -- one value: nil
print(returnsNothing()) -- (blank)
print(returnsNil()) -- nil
保持各分支的返回结构一致。如果函数返回 T?,无值分支必须 return nil,不能只写 return。
字符串枚举 + exhaustiveMatch
唯一应使用的枚举形式:
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
end
不要使用 makeEnum({...}) 库,也不要使用 Color = { red = "red" :: Color } 包装器。它们并不比字符串联合类型多提供任何好处。
参见 examples/exhaustive-match.luau。
断言
- 始终传入错误消息。 裸写
assert(cond)只会产生无用的assertion failed!。 - 消息只能是常量。
assert每次调用都会计算消息。要进行格式化,请使用if not cond then error(\...`) end`。 - 不要用
assert(typeof(x) == ...)来补救类型问题。 如果代码严格使用类型,这行代码只会在无类型调用者传入错误值时触发——应修正上游类型。 - 在信任边界处(RemoteEvents、JSON、插件输入),将输入类型标注为
unknown并进行收窄:
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)
将参数类型标注为 number,会让运行时检查看起来在静态上总是有效,即使攻击者发送的是字符串。unknown 会强制进行检查。
参见 examples/remote-validation.luau。
模块
--!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)
- 始终赋值给一个具名局部变量并
return它。 不要写return function() ... end——这会导致在 Web 界面和rg中无法使用 Ctrl-Shift-F 搜索。 - 每个工具函数单独放在一个文件中。 不要创建
TableUtil这类巨型模块;将flatten.luau和reverse.luau分开存放。Luau LSP 会自动导入裸名称。项目结构方面的原因见roblox-systems。 - 对返回的模块表调用
table.freeze,以便捕获意外修改。 - 循环依赖: 将导入的模块强制转换为
any以打破循环,并说明原因。
浅拷贝,而非深拷贝
进行不可变更新时,只克隆你要修改的路径:
items = table.clone(items)
items[1] = table.clone(items[1])
items[1].durability -= 10
在不可变模式下,深拷贝始终是多余的工作——无关子树会保留其标识,这正是跨渲染进行 == 检查所需要的。
参见 examples/module-pattern.luau。
面向对象编程——默认使用自由函数
优先使用纯数据和自由函数,而非类。元表会妨碍类型检查器、降低 grep 的效果,也会让读者感到意外。
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 videoLength
除了 __index 和 __tostring(以及少见的弱表所用的 __mode)以外,避免使用任何元方法。 不要使用 __call、__add 或运算符重载——它们无法自动补全,还会妨碍类型推断。
确实需要类时,请参见 references/typed-class-recipe.md。
验证规则
- 不要假设 Lua API 在 Luau 中存在。 有些 API 已被移除或迁移:
setfenv/getfenv——已移除。unpack(全局函数)——已移至table.unpack。loadstring——已移除;只有在明确启用时才能使用等效的loadstring,否则不可用。bit32——仍然存在,但在可能的情况下应考虑使用原生位运算符。goto/ 标签——Luau 不支持。
- 不要凭空编造语法。 如果不确定某个功能(例如
const、@native、类型函数、默认泛型)是否存在或是否稳定,请先查看 luau.org。回复时应说明不确定之处,而不是猜测。 - 稳定的概念胜过脆弱的函数签名。 如果可以将规则表述为“使用类型系统防止错误”,而非“某个标准库函数恰好接受这些参数”,就采用前者——Luau 的标准库会不断演进,类型原则则不会。
- Roblox API 不在此处讨论范围内。 对于
RemoteEvent、DataStore、Instance、服务等内容,请参考roblox-dev技能。此技能仅涵盖语言本身。
反模式
| ❌ 不要这样做 | ✅ 应该这样做 |
|---|---|
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 | 直接使用 table.insert |
call "x" / call { ... } | call("x") / call({ ... }) |
| 可重新赋值的常量 | const MAX = 100 |
| 全局变量 | local / const |
any | unknown + 类型收窄 |
| 无意义的局部类型注解 | 依靠类型推断 |
| 未标注类型的公共函数 | 标注参数和返回值 |
用 assert(typeof(x) == "T") 修补类型 | 修复上游类型 |
assert(cond) 不带消息/使用格式化消息 | 使用常量消息;格式化消息则用 if … then error(…) |
pcall(part.Destroy, part) | pcall(function() part:Destroy() end) |
键可能缺失时使用 { [K]: V } | { [K]: V? } |
T? 函数中直接 return | 显式使用 return nil |
for _, m in c:GetChildren() do require(m) | 静态 require(c.X) |
| 分段的 require 代码块 | 单个按字母排序的代码块 |
可能让函数挂起却缺少 Async 后缀 | 添加 Async 后缀 |
makeEnum({"red","green"}) | type Color = "red" | "green" + exhaustiveMatch |
__call / __add / 运算符元方法 | 普通函数 |
| 匿名模块返回值 | local foo = ...; return foo |
TableUtil 巨型模块 | 每个文件只包含一个函数 |
| 不可变更新时进行深拷贝 | 只克隆受影响的路径 |
math.floor(a / b) | a // b |
| 修改模块表 | 对返回值使用 table.freeze |
| 金字塔式嵌套 | 提前返回 + continue |
示例
examples/good.luau— 使用所有推荐模式的地道 Luau ModuleScript。examples/bad.luau— 使用所有反模式实现相同逻辑的示例。仅供对比。examples/exhaustive-match.luau— 字符串枚举 +exhaustiveMatch助手函数。examples/remote-validation.luau— 在 RemoteEvent 边界使用unknown+ 类型收窄。examples/module-pattern.luau— 简洁的模块骨架。