lloyAlloy on GitHub
Attributes
Docs

Attributes

Updated

The @ marks on a declaration, and where each one is allowed.

@alias

import { Serialize, Deserialize } from "@alloy/std/serde"

@derive(Serialize, Deserialize)
struct Player
    @alias("hp")
    health: number
end

local p = Player.from_table({ hp = 50 })
p.hp -= 10    -- the same slot as p.health

Another key for a field. On a value, the alias reads and writes the field's own slot: no copy, one value under two names, and the type holds both. Deserialize reads the field from the alias key when the table lacks its own key, the way serde's alias does, and Serialize writes the field's key alone. An alias that names a field of the struct, or one two fields take, reports.

Applies to field

@allow

@allow(too_many_arguments)
function spawn(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) end

@allow(flux.unused_variable, luau.LocalShadow)
local function setup() end

Quiets lints over what it sits on, the way Rust's #[allow] does: a function, a local, a struct, a field, an enum, a variant, an impl, a namespace, an attribute declaration, or a statement. A name is a lint, a group (@allow(pedantic)), or a lint under its tool: flux. for the compiler's, luau. for luau-lsp's, alx. for the markup lints, and an ingot's name for its own. The rustc and Clippy names of the lints Alloy has read as those lints, and clippy. is a tool like flux.: unused_variables, unused_imports, dead_code, needless_return, let_and_return, print_stdout, and todo. A name no lint has is an error, and so is an error kind: @allow quiets lints, and --@alloy-expect-error keeps an error the author means.

`@allow` or a directive. Reach for @allow when one lint is wrong about one declaration: the name says which lint, the attribute moves with the code, and a typo reports. Reach for a --@alloy- comment when the thing to quiet is no declaration or no lint: --@alloy-ignore for one line the checker misreads, --@alloy-ignore-start and -end for a run of statements inside a body, --@alloy-lint name=allow for a whole file, --@alloy-nocheck for a generated or vendored file, and --@alloy-expect-error for an error a test means. alloy doc directives lists them.

Applies to function · local · struct · field · enum · variant · impl · trait · interface · namespace · remote · message · type · attribute · statement

@cfg

@cfg(condition)
@cfg(server)
function save(player: Player) end

@cfg(server)
print("the server starts")

@cfg(client and not studio)
const hud = new Instance("ScreenGui")

Code for one side. One rule covers every form: code with nothing to give does nothing on the wrong side; code that owes the caller a value says it cannot run there.

A function that gives back no value returns at once where the condition fails. This is what a shared bootstrap needs: startup calls start_server and start_client, and each half runs on its own side. A function that gives back a value keeps its type and raises there. A silent nil where the signature promises a Player breaks somewhere else instead of naming the mistake, which is calling a server function from the client.

The signature decides. function start() with no return of a value skips, function start(): () skips, and function key(): string raises. An optional return type raises too: the type says the caller gets a value, and nil is one of its answers.

A statement runs where the condition holds and the other side skips it. A statement gives nobody a value, so a skip tells no lie. The emit is a guard on the statement's own line: if __alloy.cfg.server() then start_server() end. Two @cfg lines over one statement read as one condition, joined by and. A @cfg on a return changes what the rest of the block does on each side, so read the statements under it. @cfg is the only attribute a statement takes; every other one goes on a declaration.

A local reads its value only where the condition holds, and stays typed as the value; elsewhere it is nil. On a namespace, each member reaches the table only where the condition holds, so the name is nil on the other side.

The conditions: server, client, studio, edit, running, and test (an alloy test run). Join them with not, and, or, or any(...) and all(...).

A shared module loads on both sides, so no form is removed at build time. Each condition is a read of RunService when the code runs.

Applies to function · local · namespace · statement

@checked

@checked
function f(x: number) end

Luau's own: the runtime checks the argument types of a native-typed function. Passes through to the emit.

@deny_unknown_fields

import { Deserialize, deny_unknown_fields } from "@alloy/std/serde"

@derive(Deserialize)
@deny_unknown_fields
struct Settings
    volume: number
end

Settings.from_table({ volume = 1, volumme = 2 })  -- raises: Settings has no field volumme

from_table raises on a key the struct neither names nor aliases, as serde's option does. A typo in stored data then shows up at the load instead of as a field that quietly keeps its default. It needs Deserialize derived. It comes from @alloy/std/serde with the derives: import { deny_unknown_fields } from "@alloy/std/serde", or @serde.deny_unknown_fields under import * as serde from "@alloy/std/serde".

Applies to struct

@deprecated

@deprecated(message: string)
@deprecated({ use = "new_name", reason = "slow" })
@deprecated({ use = "mix", reason = "renamed" })
function blend() end

@deprecated("renamed to mix")
function fade() end

Luau's own: a call to the function is a lint. The argument is a message, or the table Luau's list takes: use, the name to call instead, and reason, each a string. The emit writes both as Luau reads them, @[deprecated {reason = message}] for a message and the table as written. Luau's list form, @[deprecated {use = "mix"}], passes through too.

@derive

@derive(Eq, Debug, Clone)
struct Point
    x: number
    y: number
end

Generates methods from the field list: Eq or PartialEq is __eq, Ord is __lt and __le over the fields in order, Debug is debug and __tostring, Clone is clone, Default is default, Serialize is to_table and serialize, Deserialize is from_table. Serialize and Deserialize come from @alloy/std/serde. On an enum, Eq, PartialEq, Clone, and Debug derive. Every enum prints through __tostring, and Debug adds debug; a unit variant is a string, so call E.debug(v). debug prints every variant under the enum's name: Event.Quit and Event.Scored(3).

@f32

remote Heal(@f32 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 32-bit float. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@i16

remote Heal(@i16 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 16-bit signed. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@i32

remote Heal(@i32 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 32-bit signed. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@i8

remote Heal(@i8 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 8-bit signed. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@immediate

@immediate
remote ...

A fire goes out at once instead of joining the per-frame batch: for a remote whose latency matters more than its bandwidth.

Applies to remote

@inline

@inline
function small() end

Luau's own: asks the compiler to inline the function. Passes through to the emit.

@native

@native
function hot() end

@[native]
function also_hot() end

Luau's own: compiles the function natively. Passes through to the emit. Luau's list form, @[native, deprecated], works too, so a Luau file stays valid Alloy; the list takes Luau's attributes alone, checked, native, and deprecated.

@noinline

@noinline
function big() end

Luau's own: keeps the function out of line. Passes through to the emit.

@ratelimit

@ratelimit(count: number, seconds: number)

A per-player token bucket on the server, for client-to-server remotes. Each message counts once, however many on handlers listen, and a player's bucket goes when the player leaves.

@rename

import { Serialize, rename } from "@alloy/std/serde"

@derive(Serialize)
struct Stats
    @rename("regen_per_second")
    regen: number
end

The key a field takes in the tables @derive(Serialize) writes and reads. The key is the text the literal stands for, so 'it\'s' and "it's" name one key. It comes from @alloy/std/serde with the derives: import { rename } from "@alloy/std/serde", or @serde.rename under import * as serde from "@alloy/std/serde".

Applies to field

@rename_all

import { Serialize, Deserialize, rename_all } from "@alloy/std/serde"

@derive(Serialize, Deserialize)
@rename_all("camelCase")
struct Save
    max_health: number    -- the key is maxHealth
end

The case every key of the derived table takes, serde's styles: "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", and "SCREAMING-KEBAB-CASE". A field's @rename wins over it. It needs Serialize or Deserialize derived. It comes from @alloy/std/serde with the derives: import { rename_all } from "@alloy/std/serde", or @serde.rename_all under import * as serde from "@alloy/std/serde".

Applies to struct

@sealed

@sealed
struct Config
    volume: number
end
local c = new Config { volume = 1 }
c.volume = 2  -- fine: declared

A typo in a field name is an error:

c.volme = 2   -- error: Config has no field volme

A struct is open: a write to a name it does not declare makes a new key, and a typo goes unnoticed. @sealed makes that write an error at runtime, with the struct's name and the key in the message, and the check artifact rejects it from the table type. A declared field set to nil writes through. To stop writes to a declared field, mark it read.

Emits __newindex on the class table.

@skip

import { Serialize, skip } from "@alloy/std/serde"

@derive(Serialize)
struct Session
    @skip
    connection: RBXScriptConnection?
end

Leaves a field out of the tables @derive(Serialize) writes and reads: a handle, a cache, anything that is not data. It comes from @alloy/std/serde with the derives: import { skip } from "@alloy/std/serde", or @serde.skip under import * as serde from "@alloy/std/serde".

Applies to field

@test

@test
function name() ... end

@test
namespace Suite ... end

A test beside the code it tests. The ship artifact blanks it, line for line; the check artifact keeps it typed; alloy test writes it into a lest spec with everything it reaches. An async test is awaited.

On a namespace, every public function of the group is a test, named Suite.name in the report and called by that path. A private member stays out, and so does a member that binds no function. The mark reaches a public nested namespace too, since the group means every public name in it is a test. A member that carries its own @test runs once.

Applies to function · namespace

@timeout

@timeout(seconds: number)

On a remote function: a call that gets no answer in the window rejects, with the remote's name and the seconds in the error.

@u16

remote Heal(@u16 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 16-bit unsigned. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@u32

remote Heal(@u32 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 32-bit unsigned. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@u8

remote Heal(@u8 amount: number) from client

The wire width of a number on a remote parameter or a struct field: 8-bit unsigned. The remote packs it into the buffer at that width, a field when its struct crosses a remote, and a value that does not fit raises at the fire with the path of the value: Sync: argument 1.hp: 300 does not fit @u8. Without a width a number crosses as 64 bits.

@unreliable

@unreliable
remote ...

An UnreliableRemoteEvent behind the remote: a fire may drop or arrive out of order, which suits state the next fire replaces. Roblox drops a payload over 900 bytes.

@validate

@validate(fn: (sender: Player, ...args) -> boolean)

A server-side predicate, fn(sender, ...args), run before the handler: a false drops the event, or answers the call with nil, and the handler never sees it. Defaults fill in after it.

@wire

@wire(buffer)
remote Sync(stats: Stats) from server

@wire(table)
remote Log(line: string, level: number) from server

How the payload of a remote travels: buffer packs it by the layout, and table sends it as Roblox encodes a table, checked on arrival against the same layout. Without it the compiler picks the one that sends fewer bytes. Under @wire(buffer) a part the layout cannot open, a map or an untyped table, reports. alloy doc wire explains the layout.

Applies to remote