Schema
This page covers Fx.S (also available as Fx.Schema), runtime validation for anything that crosses a trust boundary: net event payloads, RPC input and output, NUI messages, convars, config files, KVP and state bags. A schema checks a value and returns a cleaned copy with defaults and transforms applied, or a message that says what is wrong and where. Fx.net.handle, Fx.rpc.register, Fx.nui.handle, Fx.config, Fx.kvp and Fx.state take these schemas directly (see Net and RPC and FiveM modules).
Examples print values with a --> result comment showing what they print.
local S = Fx.S
local Deposit = S.struct {
amount = S.int:between(1, 1000000),
account = S.enum { 'cash', 'bank' }:default('bank'),
note = S.string:maxChars(64):optional(),
}
local ok, value = Deposit:validate({ amount = 500, hacked = true })
print(ok, value.amount, value.account, value.hacked) --> true 500 bank nil
print(Deposit:validate({ amount = 0 })) --> false amount: must be between 1 and 1000000
Rules that apply everywhere
- Output is a copy. Containers (
struct,array,tuple,record,tagged) build new tables. The input is never modified. - Unknown struct fields are dropped by default.
S.struct(fields, { extra = 'error' })rejects them and{ extra = 'keep' }passes them through unchecked. See S.struct. - Missing means
nil. A struct field that is absent is checked asnil, so it fails unless the field schema isoptional(), has adefault(...), or acceptsnil(S.unknown,S.any,S['nil']). nilis handled before the type check. For anilinput, adefaultis used if there is one, elseoptional()accepts it; refinements do not run. SoS.int:optional():min(1)andS.int:min(1):optional()both acceptnil.- Defaults are not validated. The default value (or the result of the default function) is returned as is, without type checks, refinements or transforms.
- Schemas are immutable. Every modifier returns a new schema; the original is unchanged and can be reused.
- Refinements are not type guards. They run after the base check and compare with Lua operators, so put them on the right base type.
S.string:min(1)orS.int:minLen(1)raise a Lua error (attempt to compare ...) when validated, instead of returningfalse. - Fields are checked in sorted key order, and the first problem is reported.
Error messages
A failed check returns one message: <path>: <problem>, or just <problem> at the top level.
- Paths join struct keys with
.and array or tuple indexes with[i]:items[2].id: expected integer, got "x",[2]: expected string, got nil(top-level tuple),car.plate: expected string, got 1. - Values are described as: strings quoted (
"x"), orstring of length Nwhen longer than 32 bytes; numbers, booleans andnilwithtostring; any other type by its type name (table,function). schema:decodeandschema:parsewrap the message in aParseError { message = message }.
local S = Fx.S
local Order = S.struct { items = S.array(S.struct { id = S.int, qty = S.int:between(1, 10) }) }
print(Order:validate({ items = { { id = 1, qty = 2 }, { id = 'x', qty = 1 } } })) --> false items[2].id: expected integer, got "x"
print(S.int:validate(string.rep('9', 40))) --> false expected integer, got string of length 40
Checking values
schema:validate(input)
schema:validate(input: any) -> (true, T) | (false, string)
- Returns:
trueand the decoded value, orfalseand the message. - Notes: never raises for bad input (only for misuse such as a refinement on the wrong base type).
local S = Fx.S
print(S.int:validate(3.0)) --> true 3
print(S.int:validate('3')) --> false expected integer, got "3"
schema:decode(input)
schema:decode(input: any) -> Effect<T, ParseError>
- Returns: an effect that succeeds with the decoded value.
- Fails with:
ParseError { message = <message> }. A Lua error raised during the check is a defect.
local S = Fx.S
local Amount = S.int:positive()
local exit = Fx.runSyncExit(Amount:decode(-5))
local e = Fx.Cause.failureOption(exit.cause)
print(e._tag, e.message) --> ParseError must be positive
print(Fx.runSync(Amount:decode(250))) --> 250
schema:parse(input)
schema:parse(input: any) -> T
- Returns: the decoded value.
- Fails with: raises a
ParseErrortable (a tagged error) witherror(..., 2). Inside effect code, that becomes aParseErrorfailure.
local S = Fx.S
local ok, err = pcall(function() return S.string:parse(42) end)
print(ok, err._tag, tostring(err)) --> false ParseError ParseError: expected string, got 42
local eff = Fx.gen(function() return S.int:parse('lots') end)
print(Fx.Cause.failureOption(Fx.runSyncExit(eff).cause)._tag) --> ParseError
schema:is(input)
schema:is(input: any) -> boolean
- Returns:
truewhen the input passes. Only the boolean, no value.
local S = Fx.S
print(S.netId:is(12), S.netId:is(0)) --> true false
Primitives
S.string
S.string: Schema<string>
- Notes: any Lua string. Use
minChars/maxCharsfor user-visible length andminLen/maxLenfor byte length.
print(Fx.S.string:validate(5)) --> false expected string, got 5
S.number
S.number: Schema<number>
- Notes: any finite number. NaN,
math.hugeand-math.hugeare rejected. Strings are not converted.
local S = Fx.S
print(S.number:is(1.5), S.number:is(0 / 0), S.number:is('1')) --> true false false
S.int and S.integer
S.int: Schema<integer>
S.integer: Schema<integer> -- same schema
- Notes: an integer. A float with no fractional part (
3.0, as JSON decoding often produces) is accepted and converted to a Lua integer.2.5and strings are rejected.
local S = Fx.S
local _, v = S.int:validate(3.0)
print(math.type(v), S.int:is(2.5)) --> integer false
S.boolean
S.boolean: Schema<boolean>
print(Fx.S.boolean:validate('true')) --> false expected boolean, got "true"
S.unknown and S.any
S.unknown: Schema<any>
S.any: Schema<any> -- same schema
- Notes: accepts every value, including
nil. An explicit opt-out of validation for one field.
local S = Fx.S
local Meta = S.struct { id = S.int, extra = S.unknown }
local ok, v = Meta:validate({ id = 1, extra = { anything = true } })
print(ok, v.extra.anything) --> true true
S['nil']
S['nil']: Schema<nil>
- Notes: accepts only
nil(nilis a Lua keyword, hence the brackets).
print(Fx.S['nil']:validate(1)) --> false expected nil, got 1
Literals
S.literal(...)
S.literal(...: any) -> Schema<any>
- Parameters: the allowed values, compared with
==. - Notes: the message lists them:
expected "cash" | "bank", got "x". The schema has avaluesfield with the list.
local S = Fx.S
local Seat = S.literal(-1, 0, 1, 2)
print(Seat:is(0), Seat:validate(5)) --> true false expected -1 | 0 | 1 | 2, got 5
S.enum(list)
S.enum(list: any[]) -> Schema<any>
- Parameters: a list of allowed values. Same as
S.literal(table.unpack(list)).
local Account = Fx.S.enum { 'cash', 'bank', 'crypto' }
print(Account:validate('gold')) --> false expected "cash" | "bank" | "crypto", got "gold"
Containers
S.array(item)
S.array(item: Schema<T>) -> Schema<T[]>
- Parameters:
item, the schema for every element. - Notes: the input must be a table whose keys are exactly the integers
1..#t. A table with any other key (x = 3, or a hole such as{ [1] = 1, [3] = 3 }) is rejected withexpected array, got table with key .... An empty table is an empty array. UsemaxItems(n)to cap the length (always cap arrays from clients).
local S = Fx.S
local Plates = S.array(S.string:maxChars(8)):maxItems(5)
print(Plates:is({ 'ABC123', 'XYZ789' }), Plates:is({ 'ABC', x = 1 })) --> true false
print(Plates:validate({ 'ABC123', 12 })) --> false [2]: expected string, got 12
S.tuple(...)
S.tuple(...: Schema) -> Schema<any[]>
- Parameters: one schema per position.
- Notes: a missing position is checked as
nil. Elements beyond the last schema, or non-integer keys, are rejected withunexpected tuple element <key>.
local S = Fx.S
local Coords = S.tuple(S.number, S.number, S.number)
print(Coords:is({ 215.4, -810.1, 30.7 })) --> true
print(Coords:validate({ 1, 2, 3, 4 })) --> false unexpected tuple element 4
S.struct(fields, opts?)
S.struct(fields: table<string, Schema>, opts?: { extra?: 'strip' | 'error' | 'keep' }) -> Schema<table>
S.object(fields, opts?) -- same function
- Parameters:
fields, a table from key to schema.opts.extra, what to do with keys of the input that are not infields:'strip'(default): drop them from the output;'error': fail with<key>: unexpected field;'keep': copy them to the output unchecked.
- Returns: a schema whose output holds the decoded known fields (and, with
'keep', the extra ones). - Notes: non-table input fails with
expected object, got .... The schema has afieldsfield.
local S = Fx.S
local Buy = S.struct { item = S.string:maxChars(32), qty = S.int:between(1, 10) }
local _, v = Buy:validate({ item = 'water', qty = 2, price = 0 })
print(v.item, v.qty, v.price) --> water 2 nil
local Strict = S.struct({ item = S.string }, { extra = 'error' })
print(Strict:validate({ item = 'water', price = 0 })) --> false price: unexpected field
S.record(keySchema, valueSchema)
S.record(keySchema: Schema<K>, valueSchema: Schema<V>) -> Schema<table<K, V>>
- Parameters: a schema for every key and one for every value.
- Returns: a schema whose output uses the decoded keys and values.
- Notes: the error path is the key:
bob: expected integer, got "x". Cap the size withmaxEntries(n).
local S = Fx.S
local Inventory = S.record(S.string:maxChars(32), S.int:nonNegative()):maxEntries(50)
print(Inventory:is({ water = 3, bread = 1 })) --> true
print(Inventory:validate({ water = -1 })) --> false water: must be >= 0
S.union(...)
S.union(...: Schema) -> Schema<any>
- Parameters: the member schemas, tried in order.
- Returns: a schema whose output is that of the first member that matches.
- Notes: when none match, the message lists every member's message:
no union member matched (<msg1>; <msg2>). The schema has amembersfield.
local S = Fx.S
local Target = S.union(S.int:positive(), S.string)
print(Target:is(5), Target:is('all')) --> true true
print(Target:validate(true)) --> false no union member matched (expected integer, got true; expected string, got true)
S.tagged(field, variants)
S.tagged(field: string, variants: table<string, Schema>) -> Schema<table>
- Parameters:
field, the discriminator key.variants, a table from discriminator value to schema (usually a struct). - Returns: a schema that picks the variant by
input[field], validates the whole input with it, and copies the discriminator into the output. - Notes: the variant struct does not need to list
field. An unknown discriminator fails with<field>: unknown variant <value>.
local S = Fx.S
local Payment = S.tagged('type', {
cash = S.struct { amount = S.int:positive() },
card = S.struct { amount = S.int:positive(), last4 = S.string:len(4) },
})
local _, v = Payment:validate({ type = 'card', amount = 90, last4 = '4242', cvv = '123' })
print(v.type, v.amount, v.last4, v.cvv) --> card 90 4242 nil
print(Payment:validate({ type = 'crypto', amount = 1 })) --> false type: unknown variant "crypto"
S.lazy(fn)
S.lazy(fn: fun(): Schema) -> Schema<any>
- Parameters:
fn, returns the schema. It is called on first use and cached. - Notes: for recursive schemas.
local S = Fx.S
local Menu
Menu = S.struct {
label = S.string,
children = S.array(S.lazy(function() return Menu end)):optional(),
}
print(Menu:is({ label = 'Garage', children = { { label = 'Store vehicle' } } })) --> true
print(Menu:validate({ label = 'Garage', children = { { label = 5 } } })) --> false children[1].label: expected string, got 5
FiveM types
S.vector2, S.vector3 and S.vector4
S.vector2: Schema<vector2>
S.vector3: Schema<vector3>
S.vector4: Schema<vector4>
- Notes:
- Accepts a CfxLua vector of the same size, a table
{ x = , y = , z = , w = }, or a list{ x, y, z, w }(named components win over positions). - Every component must be a finite number; the error path names it:
z: expected number, got nil. - In FiveM the output is a real
vector2/vector3/vector4. Outside FiveM (tests, where thevector3global does not exist) it is a plain table{ x = , y = , z = }. - A vector of another size is rejected (
expected vector3, got vector4).
- Accepts a CfxLua vector of the same size, a table
local S = Fx.S
local Spawn = S.struct { coords = S.vector3, heading = S.number:between(0, 360) }
local _, v = Spawn:validate({ coords = { 215.4, -810.1, 30.7 }, heading = 90 })
print(v.coords.x, v.coords.z) --> 215.4 30.7
print(S.vector3:validate({ x = 1, y = 2 })) --> false z: expected number, got nil
S.hash
S.hash: Schema<integer>
- Notes: accepts an integer hash as is, or a non-empty string that is hashed with
joaat(orGetHashKeywhenjoaatis missing). Floats and empty strings are rejected. Outside FiveM, where neither function exists, a string is returned unchanged.
local S = Fx.S
print(S.hash:is('adder'), S.hash:is(-1216765807), S.hash:is('')) --> true true false
S.netId
S.netId: Schema<integer> -- S.int:positive()
- Notes: a network id: a positive integer. It does not check that the entity exists; use
Fx.entity.fromNetIdfor that (FiveM modules).
print(Fx.S.netId:validate(0)) --> false must be positive
S.player
S.player: Schema<integer>
- Notes: server only. An integer server id for which
GetPlayerName(tostring(id))is notnil, that is, a connected player. WhenGetPlayerNamedoes not exist (tests) it only checksid > 0. Failure message:not a connected player. On the client,GetPlayerNametakes a player index, not a server id, so do not use this schema there.
local S = Fx.S
local Give = S.struct { target = S.player, amount = S.int:between(1, 10000) }
print(Give:is({ target = 1, amount = 50 })) --> true
print(Give:validate({ target = 99, amount = 50 })) --> false target: not a connected player
Modifiers
schema:optional()
schema:optional() -> Schema<T | nil>
- Returns: a schema that also accepts
nil(a missing field). A missing optional field is absent from the struct output.
local S = Fx.S
local Msg = S.struct { text = S.string, color = S.string:optional() }
local _, v = Msg:validate({ text = 'hi' })
print(v.text, v.color) --> hi nil
schema:default(value)
schema:default(value: T | fun(): T) -> Schema<T>
- Parameters:
value, used when the input isnil. A function is called each time to produce it (use a function for table defaults, so each output gets a fresh table). - Notes: the default is not validated, refined or transformed.
local S = Fx.S
local Settings = S.struct {
volume = S.number:between(0, 1):default(0.5),
favourites = S.array(S.string):default(function() return {} end),
}
local _, v = Settings:validate({})
print(v.volume, #v.favourites) --> 0.5 0
schema:describe(text)
schema:describe(text: string) -> Schema<T>
- Returns: the same schema with a
descriptionfield set totext. It does not change validation.
print(Fx.S.int:describe('Amount in dollars').description) --> Amount in dollars
schema:refine(pred, message?)
schema:refine(pred: fun(value: T): (boolean, string?), message?: string) -> Schema<T>
- Parameters:
pred, called with the decoded value after the base check passes; returnstrue, orfalseand an optional message.message, used whenpredgives none. - Notes: message order: the one
predreturns, elsemessage, elsefailed refinement. It is prefixed with the path.predis not wrapped inpcall, so an error it raises propagates.
local S = Fx.S
local Plate = S.string:refine(function(p)
if p:find('%s') then return false, 'no spaces allowed' end
return #p <= 8
end, 'at most 8 characters')
print(Plate:validate('AB 12')) --> false no spaces allowed
print(Plate:validate('ABCDEFGHI')) --> false at most 8 characters
schema:transform(fn)
schema:transform(fn: fun(value: T): U) -> Schema<U>
- Parameters:
fn, maps the decoded value; its return value is the output. It runs inpcall: raising rejects the input withtostring(err)as the message. - Notes: an error raised with
error('msg')carries Lua'sfile:line:prefix; useerror('msg', 0)for a clean message. Refinements added aftertransformsee the transformed value.
local S = Fx.S
local Plate = S.string:transform(function(p)
local clean = p:upper():gsub('%s', '')
if #clean > 8 then error('plate too long', 0) end
return clean
end)
print(Plate:validate('ab 123')) --> true AB123
print(Plate:validate('abcdefghijk')) --> false plate too long
Refinements
All refinements are built on refine and keep the path prefix. Numeric ones need a number base (S.number, S.int); length ones need a string or table base; pattern, minChars and maxChars need a string base.
schema:min(n), schema:max(n) and schema:between(lo, hi)
schema:min(n: number) -> Schema<T> -- message: must be >= n
schema:max(n: number) -> Schema<T> -- message: must be <= n
schema:between(lo: number, hi: number) -> Schema<T> -- inclusive; message: must be between lo and hi
local S = Fx.S
print(S.int:min(18):validate(16)) --> false must be >= 18
print(S.int:between(1, 100):validate(500)) --> false must be between 1 and 100
schema:positive() and schema:nonNegative()
schema:positive() -> Schema<T> -- > 0; message: must be positive
schema:nonNegative() -> Schema<T> -- >= 0; message: must be >= 0
local S = Fx.S
print(S.number:positive():is(0), S.int:nonNegative():is(0)) --> false true
schema:minLen(n), schema:maxLen(n) and schema:len(n)
schema:minLen(n: integer) -> Schema<T> -- message: length must be >= n
schema:maxLen(n: integer) -> Schema<T> -- message: length must be <= n
schema:len(n: integer) -> Schema<T> -- message: length must be n
- Notes: for strings the length is in bytes (
#s). For tables it is the number of entries (every key counted withpairs, not#t).
local S = Fx.S
print(S.string:maxLen(3):is('héé')) --> false
print(S.string:len(4):validate('123')) --> false length must be 4
schema:minItems(n), schema:maxItems(n) and schema:maxEntries(n)
schema:minItems(n: integer) -> Schema<T> -- same as minLen
schema:maxItems(n: integer) -> Schema<T> -- same as maxLen
schema:maxEntries(n: integer) -> Schema<T> -- same as maxLen
- Notes: aliases that read better on arrays and records.
local S = Fx.S
print(S.array(S.int):maxItems(2):validate({ 1, 2, 3 })) --> false length must be <= 2
schema:nonEmpty()
schema:nonEmpty() -> Schema<T> -- minLen(1); message: length must be >= 1
print(Fx.S.string:nonEmpty():validate('')) --> false length must be >= 1
schema:minChars(n) and schema:maxChars(n)
schema:minChars(n: integer) -> Schema<string> -- message: must be at least n characters
schema:maxChars(n: integer) -> Schema<string> -- message: must be at most n characters
- Notes: length in characters (UTF-8 code points,
utf8.len), not bytes. Invalid UTF-8 fails. The JS side counts the same way. Prefer these for names, messages and anything a player types.
local S = Fx.S
local Name = S.string:minChars(2):maxChars(3)
print(Name:is('héé'), Name:is('h'), S.string:maxChars(10):is('\xff\xfe')) --> true false false
schema:pattern(p, message?)
schema:pattern(p: string, message?: string) -> Schema<string> -- default message: must match <p>
- Parameters:
p, a Lua pattern (not a regular expression). Anchor it yourself with^and$; without anchors it matches anywhere in the string.
local S = Fx.S
local Plate = S.string:pattern('^%u%u%u%d%d%d$', 'plate must look like ABC123')
print(Plate:is('ABC123'), Plate:validate('abc123')) --> true false plate must look like ABC123
print(S.string:pattern('%d'):is('abc1')) --> true
Schemas in tagged errors
Fx.TaggedError(tag, fields) takes a table of schemas and checks every instance when it is built; see Errors.
local S = Fx.S
local NotEnoughMoney = Fx.TaggedError('NotEnoughMoney', { need = S.int })
print(pcall(NotEnoughMoney, { need = 'x' })) --> false invalid fields for NotEnoughMoney: need: expected integer, got "x"