mcpField
L
luau-expert

luau-expert

github.com/afrxo开发1270 次安装26 周前更新
代码仓库

关于 luau-expert

面向 Luau 开发者的现代严格类型编程技能,介绍类型、语法、模块和验证实践,并提供示例;强调遵循现有项目约定,不擅自改写代码。

主要功能

  1. 01严格类型与类型系统指南
  2. 02现代 Luau 语法与惯用写法
  3. 03模块与不可变性模式
  4. 04信任边界输入验证
  5. 05反模式与验证规则
  6. 06参考指南与代码示例

适用场景

  1. 01编写新的严格类型 Luau 文件
  2. 02在明确要求时重构代码
  3. 03检查 Luau 类型与 API 边界
  4. 04采用符合项目风格的语言模式

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/afrxo/roblox-agent-skills --skill luau-expert

安装后在 Claude Code 中输入/luau-expert确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
.gitkeep
bad.luau
exhaustive-match.luau
good.luau
module-pattern.luau
remote-validation.luau
references/
type-functions-api.md
type-patterns.md
type-system.md
typed-class-recipe.md

详细介绍

Luau 语言

编写现代、严格类型化的 Luau。来源:luau.org。

深入了解请参阅:

延伸阅读: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 cif a then b else c
if x.Parent thenif x.Parent ~= nil then
local insert = table.insert直接使用 table.insert
call "x" / call { ... }call("x") / call({ ... })
可重新赋值的常量const MAX = 100
全局变量local / const
anyunknown + 类型收窄
无意义的局部类型注解依靠类型推断
未标注类型的公共函数标注参数和返回值
用 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

示例

评论

支持 Markdown