luau-expert について
Luau開発者向けに、現代的な厳密型付けの書き方をまとめたスキルです。型、構文、モジュール、検証の指針と例を示し、既存プロジェクトの規約を尊重して適用する方針も説明します。
主な機能
- 01厳密な型付けと型システムの指針
- 02現代的なLuau構文と慣用表現
- 03モジュールと不変性のパターン
- 04信頼境界での入力検証
- 05アンチパターンと検証ルール
- 06リファレンスとコード例
活用シーン
- 01新しい厳密型付けLuauファイルの作成
- 02明示的に依頼されたコードのリファクタリング
- 03Luauの型と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を使います。レガシーコードは移行します。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 を使います。
イディオム
早期リターン/continue
条件節をネストするより、ガード節を使います。関数が論理的に続行できない場合は、早期にリターンします。
local function dealDamage(humanoid: Humanoid, damage: number)
if damage <= 0 then return end
humanoid.Health -= damage
end
明示的な nil チェック
多くの場合、「truthy」チェックが実際に意味するのは「nil ではない」です。意図をそのまま記述してください。
if x.Parent ~= nil then ... -- ✅
if x.Parent then ... -- ❌ ambiguous
truthy を使ってよい例外は 2 つあります。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
require
- 動的な require は禁止です。
require(Modules.X)では型が維持されますが、ループ内のrequire(child)では型情報が失われます。 - アルファベット順のブロックを 1 つだけ使います。 パッケージとローカルでセクションを分けないでください。Luau LSP の auto-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 ではなく return nil を使います。
文字列列挙型+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 を使った検索が機能しなくなります。 - ユーティリティごとにファイルを1つ作ってください。
TableUtilのような巨大モジュールは避け、flatten.luauとreverse.luauを別々に作ってください。Luau LSP は名前だけで自動的にrequireします。プロジェクト構成の根拠はroblox-systemsにあります。 - 返すモジュールテーブルには
table.freezeを適用してください。 誤った変更を検出できます。 - 循環依存: 循環を解消するには、インポートしたモジュールを
anyにキャストし、その理由を記載してください。
ディープコピーではなくシャローコピー
イミュータブルな更新では、変更するパスだけをクローンしてください。
items = table.clone(items)
items[1] = table.clone(items[1])
items[1].durability -= 10
イミュータビリティのもとでは、ディープコピーは常に無駄な処理です。無関係なサブツリーは同じアイデンティティを保ちます。これは、レンダー間で == がチェックする内容そのものです。
examples/module-pattern.luau を参照してください。
OOP — デフォルトはフリー関数
クラスよりも、プレーンなデータとフリー関数を使ってください。メタテーブルは型チェッカーと相性が悪く、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相当の機能を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 接尾辞のない yield 関数 | Async を接尾辞に付ける |
makeEnum({"red","green"}) | type Color = "red" | "green" + exhaustiveMatch |
__call / __add / 演算子メタメソッド | 通常の関数 |
| 匿名のモジュール戻り値 | local foo = ...; return foo |
巨大な TableUtil モジュール | ファイルごとに関数を1つにする |
| 不変更新のためのディープコピー | 変更するパスだけを複製する |
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— すっきりしたモジュールのひな形。