roblox-luau-types
Use for Luau annotations, generics, unions, narrowing, strictness, sealed tables, module type exports, typed metatables, or new-solver features.
Install
npx skills add https://github.com/TabooHarmony/roblox-brain/tree/main/skills/core/roblox-luau-types
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tabooharmony-roblox-brain@llmmart
git clone https://github.com/TabooHarmony/roblox-brain.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tabooharmony/roblox-brain collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Luau Type System
When to Load
Load for Luau types: annotations, generics, unions, narrowing, sealed/unsealed tables, strictness (--!strict vs --!nonstrict), module type exports, metatable-backed object typing, and new-solver features (keyof, setmetatable<T, M>, type functions, read members). For syntax, use roblox-luau-core; for OOP/async, roblox-luau-patterns.
Quick Reference
Strictness: --!strict for maintained code, --!nonstrict while transitioning, --!nocheck only for legacy or generated code. Directives and project settings select the mode; never assume one global default.
New solver gate: keyof, rawkeyof, setmetatable<T, M>, type functions, and read members need the new type solver. It is on by default for nocheck/nonstrict projects; strict projects need Workspace.UseNewLuauTypeSolver = Enabled (Scripting category). Errors here usually mean the wrong solver, not wrong syntax.
Inference philosophy: Infer first; annotate boundaries (params, returns, exports). Don't annotate every local.
Sealed vs unsealed tables: An empty local t = {} stays open to new fields; annotating or passing it seals it, so later additions error. Build tables fully before annotating.
Unions: local id: string | number is a union; prefer tagged unions (type State<T> = {kind:"loading"} | {kind:"ready", value:T}) and discriminate on kind to narrow.
Narrowing: typeof(v) == "string" narrows primitives, instance:IsA("BasePart") narrows Instances, and assert(v, "msg") narrows away nil. Discriminant fields narrow tagged unions; see full reference for worked examples.
Generics: Use when input→output type matters: function first<T>(list: {T}): T?, or type Result<T> = {success: boolean, value: T?}. Never replace with any.
Type exports: export type Foo = {...} at module boundaries; consumers use Types.Foo.
Object typing: typeof(setmetatable({} :: CounterData, Counter)) types precise self; the new solver adds setmetatable<T, M> directly.
Casts (::): For narrowing overly generic inference, never for hiding errors.
Trust boundaries: Annotations contract with the compiler, not with runtime data. Remotes, DataStores, HttpService, and attributes still need checks; inside a trusted boundary, let types carry the load.
Key mistakes: any propagation in nonstrict, sealing too early, unions without discriminants, annotating every local, deleting new-solver syntax without checking the solver setting.
Full reference: see
references/full.md
Files (roblox-brain)
-
references
-
full.md 16.7 KB
# Luau Type System: Full Reference > **Code in this reference is illustrative. Adapt to your game and verify in Studio before production use.** ## Decision Rules - Use `--!strict` for new or actively maintained code - Prefer inference-preserving designs over annotation-heavy designs when inferred shape stays precise - Annotate where it clarifies intent, stabilizes contracts, constrains `self`, or prevents widening to `any` - Prefer explicit exported aliases at module boundaries for stable contracts - Use generics when input/output relationships matter; never replace with `any` - Use tagged unions + refinements for multi-case structured values - Casts (`::`) are a precision tool, not a bypass: narrow overly generic inference, don't hide errors ## Philosophy The type system exists to **catch bugs at analysis time** without affecting runtime. The goal is not "annotate everything" but "let the type checker help you." Key principles: 1. **Inference first.** If the type checker already knows the type, don't annotate it. Redundant annotations add noise and can become stale. 2. **Annotate boundaries.** Function parameters, return types, and exported module surfaces benefit from explicit types. Internal locals usually don't. 3. **Preserve relationships.** A generic `<T>` that carries a type through a transform is more valuable than `any` that erases it. 4. **Narrow, don't cast.** Use `typeof()`, `IsA()`, and conditional checks to narrow types. Use `::` only when you genuinely know more than the checker. 5. **Sealed vs unsealed matters.** An annotated table is sealed (no new fields). An unannotated local table accumulates fields until it leaves scope or gets returned. ## Strictness Modes ```luau --!strict -- Full type checking. Errors on unresolved types. Use for new code. --!nonstrict -- Permissive checking for transitional code; project settings may select it. --!nocheck -- Disables type checking entirely. Only for generated code or legacy. ``` **Solver rollout:** Roblox began general release of the New Type Solver on November 20, 2025. Nocheck and nonstrict projects migrated automatically at that point, while existing strict projects remained opt-in. Roblox also documented strict-mode compatibility, performance, memory, and correctness issues. Check the current project's Scripting settings and file directives instead of assuming one universal solver or mode. ## Basic Type Annotations ```luau -- Variable annotations local name: string = "Alice" local health: number = 100 local isAlive: boolean = true local data: any = nil -- opt out of type checking -- Function parameter and return types local function add(a: number, b: number): number return a + b end -- Optional parameters local function greet(name: string, title: string?): string if title then return `{title} {name}` end return name end ``` ## Table Types ```luau -- Array type local scores: { number } = { 100, 95, 87 } -- Dictionary type (indexer) local config: { [string]: boolean } = { shadows = true, particles = false, } -- Record type (concrete fields) type PlayerData = { name: string, level: number, inventory: { string }, stats: { health: number, mana: number, }, } ``` ### Sealed vs Unsealed Tables ```luau -- UNSEALED: unannotated local tables accumulate fields local config = {} config.debug = true -- fine, table is unsealed config.version = "1.0" -- fine, still accumulating -- SEALED: once annotated or returned, no new fields allowed local settings: { debug: boolean } = { debug = true } settings.version = "1.0" -- ERROR: 'version' not in type -- Practical implication: build tables fully before annotating local data = { name = "Alice", level = 10, } -- data is unsealed here, you can still add fields data.guild = "Warriors" -- But once you pass it to a typed function or return it, it seals ``` ## Union and Intersection Types ```luau -- Union type: value can be one of several types local id: string | number = "abc123" id = 42 -- also valid -- Optional is shorthand for T | nil local nickname: string? = nil -- equivalent to string | nil -- Tagged unions for state machines (discriminated unions) type Loading = { kind: "loading" } type Ready<T> = { kind: "ready", value: T } type Failed = { kind: "failed", message: string } type State<T> = Loading | Ready<T> | Failed local function readValue(state: State<number>): number? if state.kind == "ready" then return state.value -- narrowed to Ready<number> end return nil end ``` ## Type Narrowing and Guards ```luau -- typeof narrows types (Roblox-aware, preferred over type()) local function process(value: string | number) if typeof(value) == "string" then -- value is narrowed to string here print(string.upper(value)) else -- value is narrowed to number here print(value * 2) end end -- Instance type checking with :IsA() local function handlePart(instance: Instance) if instance:IsA("BasePart") then -- instance is narrowed to BasePart instance.Anchored = true instance.BrickColor = BrickColor.new("Bright red") end end -- assert for non-nil narrowing local function getPlayerData(player: Player): PlayerData local leaderstats = player:FindFirstChild("leaderstats") assert(leaderstats, "Player missing leaderstats") -- leaderstats is now narrowed to non-nil return parseStats(leaderstats) end ``` ## Generics ```luau -- Generic function: preserves element type through transforms local function first<T>(list: { T }): T? return list[1] end local name = first({ "Alice", "Bob" }) -- inferred as string? local num = first({ 1, 2, 3 }) -- inferred as number? -- Generic type alias type Result<T> = { success: boolean, value: T?, error: string?, } -- Generic class-like pattern type Stack<T> = { items: { T }, push: (self: Stack<T>, value: T) -> (), pop: (self: Stack<T>) -> T?, peek: (self: Stack<T>) -> T?, } ``` ### When to Use Generics - **Yes:** When a function transforms input and the output type depends on the input type - **Yes:** When a container holds items of a specific type that callers should know about - **Yes:** When you want to preserve type relationships across a chain of operations - **No:** When the type is always the same (just use the concrete type) - **No:** When you'd end up with `<any>` everywhere (you've lost the benefit) ## Type Exports ```luau -- In a ModuleScript, export types for other modules to use export type WeaponData = { name: string, damage: number, rarity: "Common" | "Rare" | "Epic" | "Legendary", durability: number, } -- Consumers import with require local Types = require(game.ReplicatedStorage.Types) local function createWeapon(name: string, damage: number): Types.WeaponData return { name = name, damage = damage, rarity = "Common", durability = 100, } end ``` ### Export Philosophy - Export named aliases for every type that crosses a module boundary - Keep implementation types internal (don't export helper types only used inside) - Choose signatures that let callers infer types cleanly without needing to import the alias - A well-typed module surface acts as documentation ## Common Roblox Types ```luau -- Instance hierarchy types local part: Part = Instance.new("Part") local player: Player = game.Players.LocalPlayer local character: Model = player.Character or player.CharacterAdded:Wait() local humanoid: Humanoid = character:FindFirstChildWhichIsA("Humanoid") :: Humanoid -- Value types (NOT instances; value types / structs) local position: Vector3 = Vector3.new(10, 5, 0) local rotation: CFrame = CFrame.new(0, 10, 0) * CFrame.Angles(0, math.rad(90), 0) local color: Color3 = Color3.fromRGB(255, 0, 0) local udim2: UDim2 = UDim2.new(0.5, 0, 0.5, 0) -- Enum types local material: Enum.Material = Enum.Material.Grass ``` ## Typing Object-Like Modules ```luau --!strict local Counter = {} Counter.__index = Counter type CounterData = { value: number } export type Counter = typeof(setmetatable({} :: CounterData, Counter)) function Counter.new(initialValue: number): Counter return setmetatable({ value = initialValue }, Counter) end -- Explicit self annotation when : syntax doesn't infer precisely enough function Counter.increment(self: Counter, amount: number): number self.value += amount return self.value end return Counter ``` ### When to Use Explicit `self` - When the type checker can't infer `self` precisely through `:` syntax - When you need `self` to be a specific subtype in an inheritance chain - When the method is defined with `.` but called with `:` (rare, avoid if possible) - In type definitions (function signatures in type aliases always need explicit self) ## New Solver Type Features <!-- temporal: 2026-09 --> Everything in this section requires the **new type solver**. The old solver cannot resolve these constructs: do not delete them, rewrite them into old-solver idioms, or report them as missing in a project still running the old solver. Roblox generalized the new solver on November 20, 2025 (see Strictness Modes above): `nocheck`/`nonstrict` projects were migrated automatically, while strict projects stay on the old solver until they opt in. Opt in per experience with the Workspace property `UseNewLuauTypeSolver` (Scripting category, set to `Enabled`); the Studio beta-feature toggle was removed on January 7, 2026. The separate `LuauTypeCheckMode` property sets the default strictness mode, not the solver. Check which solver a project uses, or set the property explicitly, before editing these features. ### keyof and rawkeyof Built-in type functions on table types. `keyof<T>` returns the keys of `T` as a union of singleton types; `rawkeyof<T>` ignores metatables. ```luau local config = { health = 10, range = 20, team = "red" } type ConfigKey = keyof<typeof(config)> -- "health" | "range" | "team" local function read(key: ConfigKey) -- callers can only pass real keys end ``` ### setmetatable<T, M> The new solver promotes `setmetatable` to a type constructor: `setmetatable<T, M>` builds the table type `T` carrying metatable `M`, without routing through `typeof(setmetatable(...))`. ```luau local Mt = {} Mt.__index = Mt type Object = setmetatable<{ value: number }, { __index: typeof(Mt) }> function Mt.new(value: number): Object return setmetatable({ value = value }, Mt) end ``` The old-solver idiom `typeof(setmetatable({} :: T, Mt))` still works under the new solver; `setmetatable<T, M>` is the clearer form there. In old-solver projects keep the `typeof` idiom, because `setmetatable<T, M>` in type position does not resolve. ### User-defined type functions Functions that run during analysis and compute a type from types. Declared with `type function`; called with angle brackets like built-ins. ```luau type function keyofLike(ty: type) if not ty:is("table") then error("keyofLike expects a table type") end local union = nil for key in ty:properties() do union = if union then types.unionof(union, key) else key end return if union then union else types.singleton(nil) end type Keys = keyofLike<{ name: string, level: number }> -- "name" | "level" ``` - Runs at analysis time only: no runtime presence, no runtime cost, and no access to runtime functions or script locals. - The environment is sandboxed and restricted: the `types` library (constructors and inspectors such as `types.unionof`, `types.singleton`, `types.newtable`, `tabletype:properties()`, `setreadproperty`/`readproperty`) plus `assert`, `error`, `print`, `next`, `ipairs`, `pairs`, `select`, `unpack`, `getmetatable`, `setmetatable`, `rawget`, `rawset`, `rawlen`, `raweq`, `tonumber`, `tostring`, `type`, `typeof`, and the `math`, `table`, `string`, `bit32`, `utf8`, and `buffer` libraries. Nothing else is available. - `error()` inside a type function surfaces as a type error at the call site. - Status: shipped upstream (luau.org/types/type-functions) and usable on Roblox under the new solver; Roblox staff have confirmed experiences can be published with them, but editor tooling inside type-function bodies is still maturing. ### read table members The new solver tracks read and write types per property. Prefix a member with `read` to make it read-only: ```luau local function describe(box: { read part: Instance }) print(box.part.Name) -- box.part = Instance.new("Part") -- type error: read-only end ``` Reads are allowed and writes are type errors, so callers can pass a narrower table (a `{ part: Part }` where `{ read part: Instance }` is expected). Functions defined with `function T.name()` syntax are inferred as read-only members. The read/write split is also visible to user-defined type functions: `ty:properties()` returns `{ [key]: { read: type?, write: type? } }`. ## Function Attributes ### @deprecated Marks a named function or property as deprecated. The linter warns at every call site and the LSP shows the entry in a distinct style in autocomplete. Both optional string parameters customize the warning: `use` names the replacement, `reason` explains. ```luau @deprecated local function oldApi() end @[deprecated { use = "newApi()", reason = "oldApi miscounts negative values" }] local function olderApi() end ``` | Form | Warning | | --- | --- | | `@deprecated` | `Function 'oldApi' is deprecated.` | | `use = "newApi()"` | adds `use 'newApi()' instead.` | | member function | `Member 'class.func' is deprecated` | Attributes apply to functions only and are not user-definable. Status: documented upstream at luau.org/attributes and parsed by Studio since release 669; upstream release 0.668 fixed propagation to anonymous functions under the new solver. Treat actual Studio warning behavior as still settling; do not claim it errors or blocks anything. ### Native codegen (--!native / @native) Roblox-only compiler feature: server-side scripts compile to machine code instead of bytecode. Covered in full in `roblox-luau-core`; the type-relevant facts: - `--!native` at the top of a Script enables it for all functions in the script (top-level scope only if deemed profitable). `@native` above an individual function enables it per function. - Enable `--!optimize 2` alongside: `--!native` benefits from aggressive optimization, and the pairing is the common performance idiom. - Annotate hot parameters (`v: Vector3`, not `v`): codegen specializes on type annotations, and wrong or missing hints add runtime checks or drop the function back to the interpreter. - Server scoping: documented for server-side scripts. Do not add `--!native` to client scripts expecting the same benefit. - Roblox publishes no official speedup number; measure with the Script Profiler instead of claiming a ratio. ## Common Mistakes - Using `keyof`, `setmetatable<T, M>`, type functions, or `read` members in an old-solver project and "fixing" the resulting errors by deleting them; they need the new solver (`UseNewLuauTypeSolver`) - Assuming upstream-only Luau features are live in the Roblox VM without checking deployment status - Leaving variables unannotated in `--!nonstrict` → unintentional `any` propagation - Replacing useful generic relationships with `any` or overly broad unions - Sealing a table too early with an annotation, then expecting to add fields later - Expecting `:` method definitions to automatically share precise `self` type across the class - Using `::` to force unrelated conversions instead of fixing underlying type design - Building unions without a discriminant, making downstream refinement difficult - Using intersections between incompatible primitives (`string & number`) - Annotating every local variable (noise that hides the important annotations) - Exporting internal helper types that clutter the module's public surface ## Solver Migration Rules - Never delete or "modernize away" new-solver syntax (`keyof`, `setmetatable<T, M>`, `type function`, `read` members) because a toolchain or collaborator reports it as unknown; see [New Solver Type Features](#new-solver-type-features) for the opt-in. - Do not add new-solver syntax to a project that has not opted in; the old solver will reject or mangle it. ## Quality Checklist - [ ] File has appropriate strictness mode (`--!strict` for maintained code) - [ ] Function parameters and return types annotated at module boundaries - [ ] Internal locals rely on inference where the inferred type is precise - [ ] Generics preserve type relationships (no `any` escape hatches) - [ ] Tagged unions have a discriminant field for narrowing - [ ] Exported types are named, focused, and documented - [ ] Casts (`::`) are justified (narrowing, not hiding errors) - [ ] No sealed table violations (fields added after annotation) - [ ] New-solver features are only used where the new solver is enabled - [ ] Time-sensitive claims (solver rollout, attributes, Studio behavior) carry a `<!-- temporal: YYYY-MM -->` marker
-
-
SKILL.md 2.9 KB
--- name: roblox-luau-types description: "Use for Luau annotations, generics, unions, narrowing, strictness, sealed tables, module type exports, typed metatables, or new-solver features." last_reviewed: 2026-09-13 sources: - https://raw.githubusercontent.com/Roblox/creator-docs/main/content/en-us/luau/type-checking.md - https://luau.org/types/type-functions/ --- # Luau Type System ## When to Load Load for Luau types: annotations, generics, unions, narrowing, sealed/unsealed tables, strictness (`--!strict` vs `--!nonstrict`), module type exports, metatable-backed object typing, and new-solver features (`keyof`, `setmetatable<T, M>`, type functions, `read` members). For syntax, use `roblox-luau-core`; for OOP/async, `roblox-luau-patterns`. ## Quick Reference **Strictness:** `--!strict` for maintained code, `--!nonstrict` while transitioning, `--!nocheck` only for legacy or generated code. Directives and project settings select the mode; never assume one global default. **New solver gate:** `keyof`, `rawkeyof`, `setmetatable<T, M>`, `type function`s, and `read` members need the new type solver. It is on by default for `nocheck`/`nonstrict` projects; strict projects need `Workspace.UseNewLuauTypeSolver = Enabled` (Scripting category). Errors here usually mean the wrong solver, not wrong syntax. **Inference philosophy:** Infer first; annotate boundaries (params, returns, exports). Don't annotate every local. **Sealed vs unsealed tables:** An empty `local t = {}` stays open to new fields; annotating or passing it seals it, so later additions error. Build tables fully before annotating. **Unions:** `local id: string | number` is a union; prefer tagged unions (`type State<T> = {kind:"loading"} | {kind:"ready", value:T}`) and discriminate on `kind` to narrow. **Narrowing:** `typeof(v) == "string"` narrows primitives, `instance:IsA("BasePart")` narrows Instances, and `assert(v, "msg")` narrows away `nil`. Discriminant fields narrow tagged unions; see full reference for worked examples. **Generics:** Use when input→output type matters: `function first<T>(list: {T}): T?`, or `type Result<T> = {success: boolean, value: T?}`. Never replace with `any`. **Type exports:** `export type Foo = {...}` at module boundaries; consumers use `Types.Foo`. **Object typing:** `typeof(setmetatable({} :: CounterData, Counter))` types precise self; the new solver adds `setmetatable<T, M>` directly. **Casts (::):** For narrowing overly generic inference, never for hiding errors. **Trust boundaries:** Annotations contract with the compiler, not with runtime data. Remotes, DataStores, HttpService, and attributes still need checks; inside a trusted boundary, let types carry the load. **Key mistakes:** `any` propagation in nonstrict, sealing too early, unions without discriminants, annotating every local, deleting new-solver syntax without checking the solver setting. > Full reference: see `references/full.md`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.