Skip to content

Argument Types

An argument type turns one piece of typed text into a real value, and optionally offers autocomplete suggestions. One definition serves both sides: the client uses it for suggestions and inline validation, the server re-runs the same transform authoritatively at execution time.

The Result convention

transform returns two values, never a wrapper object.

luau
return true, value      -- success
return false, "message" -- rejection, with a message shown to the player

Built-in types

luau
local Types = require(game:GetService("ServerStorage").Operator).Types
TypeAcceptsProduces
Types.Stringanythingstring
Types.Number42, -3.5number, rejecting NaN and infinity
Types.Integer42, -7number, rejecting fractions
Types.Booleantrue/yes/y/1/on and their negativesboolean
Types.Duration30, 30s, 5m, 1h30m, 1.5h, 2d, 1wnumber of seconds
Types.Player@me, a name, an unambiguous prefixPlayer
Types.Players@me, @all, @others, names, comma lists{ Player }
Types.Teama team name or unambiguous prefixTeam
Types.BrickColora palette colour nameBrickColor

Player and team names match case-insensitively: an exact match always wins, a unique prefix is accepted, and an ambiguous prefix is rejected with the candidates listed. Players match on both Name and DisplayName, and two labels pointing at the same player do not count as ambiguous.

Types.Players also accepts comma-separated lists such as bob,alice or @others,bob, and mixes freely with selectors. Duplicates are removed, so @all,@me yields each player once. An empty entry (bob,,alice) is rejected, and if any entry fails the whole argument fails. Types.Player rejects a comma list outright, since it resolves exactly one player.

Defining a custom type

One small file, one Type.new call.

luau
local Type = require(game:GetService("ServerStorage").Operator).Type

return Type.new("itemId")
	:transform(function(raw, executor)
		local item = ItemConfig.Items[raw]
		if item == nil then
			return false, `"{raw}" is not an item`
		end
		return true, item
	end)
	:suggestions(function(query, executor)
		return ItemConfig.NamesMatching(query)
	end, { queryDependent = true })
	:build()

executor is the player running the command, or nil when there is not one. Both callbacks may yield.

A transform must be pure

It may read game state and it may yield, but it must not change anything: no writing data, no firing remotes, no mutating shared tables, no side effects of any kind.

This is a hard contract, not a style preference. The console runs your transform locally to show inline errors as the player types, and to keep the render path from ever blocking it resumes the transform in a coroutine and closes it the moment it yields. A transform that yields is abandoned partway through. If it had already mutated something, that mutation is left half-applied, on every keystroke.

The authoritative run happens on the server, which always runs the transform to completion. So a pure transform loses nothing by being abandoned client-side, and an impure one is misusing the API. Put side effects in the command's run, where they belong.

Composing on an existing type

extends runs the base transform first and feeds its result forward, so you never reimplement parsing. If the base rejects, the chain short-circuits and the base message is returned.

luau
local PositiveInteger = Type.new("positiveInteger")
	:extends(Types.Integer)
	:transform(function(value)
		if value <= 0 then
			return false, "expected a number above zero"
		end
		return true, value
	end)
	:build()

A type that defines no suggestions of its own inherits the base ones, along with the base caching options.

Suggestions

suggestions may return a list directly or a promise-like value (anything with andThen). Requests are debounced and results cached, so a type backed by a DataStore is not hammered on every keystroke.

OptionDefaultMeaning
ttl10seconds a cached suggestion list stays fresh
debounce0.15seconds of quiet before a fetch is issued
timeout5seconds before a suggestion fetch is abandoned
queryDependentfalsewhether the result depends on what has been typed

queryDependent is the one worth understanding. By default a type ignores query and returns its whole candidate list; the cache stores one entry per type and filters by prefix on the way out, so typing ten characters costs one fetch. Set it to true when the type narrows results itself, such as a DataStore lookup over thousands of rows, and the cache will key on the query and pass results through unfiltered.

Suggestions are allowed to change between calls, so a type may depend on data that does not exist yet when the module first runs.

Running a type

Both entry points are timeout-wrapped, so a custom type that yields forever can never stall the caller.

luau
local ok, value = Type.runTransform(Types.Player, "@me", executor)
if not ok then
	warn(value)
end

local ok, suggestions = Type.runSuggestions(Types.Team, "re")

:timeout(seconds) overrides the transform timeout for one type; the default is 5 seconds. A transform that raises, times out, or returns nothing at all is reported as a normal rejection with a message. it never escapes as an error.

Caching suggestions in a UI

luau
local SuggestionCache = require(game:GetService("ServerStorage").Operator).SuggestionCache

local cache = SuggestionCache.new()
local cancel = cache:request(Types.Player, query, executor, function(suggestions)
	render(suggestions)
end)

request returns a cancel function. Give it to a Maid, or call it when the query changes. A cache hit calls back synchronously; a miss debounces first. cache:clear(typeName) drops cached entries for one type, and cache:destroy() tears everything down and suppresses pending callbacks.

Released under the MIT Licence.