Vet
Lua-first schema validation with type inference.
local v = require(ReplicatedStorage.Packages.Vet)
local Name = v.string():min(3):max(22)
Name:parse("kyle") --> "kyle"
Name:parse("ky") --> errors
Types
Issue
interface Issue {path: {string | number}--
Location of the failure inside the value
message: string--
Human-readable description of the failure
}Failure
The unsuccessful arm of Vet.Result. Naming it lets a helper return a
failure that fits any Result<T> without widening T to any.
Result<T>
Returned by safeParse. Narrow it with if result.success then to get a
typed result.value.
infer<S>
type infer<S> = thevaluetypeproducedbyschemaSExtracts the type a schema parses to, so a schema can be the single source of truth for both validation and types.
local Username = v.string():min(3)
type Username = v.infer<typeof(Username)> --> string
local Nickname = v.string():optional()
type Nickname = v.infer<typeof(Nickname)> --> string?
Given a union of schemas, the result is the union of what each one parses
to: Vet.infer<StringSchema | NumberSchema> is string | number.
This is a user-defined type function, so it runs during analysis and costs nothing at runtime. It requires a Luau version that supports user-defined type functions.
objectOutput<S>
type objectOutput<S> = therecordtypeashapeofschemasparsesto
Maps { name: StringSchema, age: NumberSchema } to { name: string, age: number }. Used to type the result of Vet.object, so the shape you pass in
is the only place the field types are written down.
A field wrapped by ReadonlySchema or WriteonlySchema carries an access
marker, which becomes a read or write qualifier on the property it
produces:
local PlayerData = v.object({
id = v.number():readonly(),
name = v.string(),
secretToken = v.string():writeonly(),
})
type PlayerData = v.infer<typeof(PlayerData)>
--> { read id: number, name: string, write secretToken: string }
The property walk duplicates the one in Vet.infer because a type function cannot call another type function.
AnySchema<T>
type AnySchema<T> = {parse: (self,unknown) → T,safeParse: (self,unknown) → Result<T>}The shape every schema shares. Constructors that take another schema accept this, so passing something that is not a schema is a type error at the call site rather than a failure at parse time.
Access
type Access = "read" | "write" | "readwrite"
What a schema's access property holds. Every schema carries one, so a
shape can be inspected without knowing which kind of schema a field is:
local Player = v.object({ id = v.number():readonly(), name = v.string() })
Player.shape.id.access --> "read"
Player.shape.name.access --> "readwrite"
Each schema type declares the single value it can hold rather than this union, which is what lets Vet.objectOutput read the marker during analysis.
Color3Schema
What Vet.color3 returns.
UDim2Schema
What Vet.udim2 returns.
UuidVersion
type UuidVersion = "v4" | "v5" | "v6" | "v7" | "v8"Versions accepted by Vet.uuid and Vet.guid. Version 4 is random, 5 is name-based, 6 is reordered time, 7 is Unix epoch time, and 8 is custom.
UuidOptions
Functions
string
ConstructorCreates a schema that accepts any string.
number
ConstructorCreates a schema that accepts any number except NaN.
boolean
ConstructorCreates a schema that accepts true or false.
color3
ConstructorCreates a schema that accepts a Color3.
local Tint = v.color3()
Tint:parse(Color3.fromRGB(255, 0, 0)) --> Color3(1, 0, 0)
Tint:parse({ R = 1, G = 0, B = 0 }) --> errors: expected Color3, got table
The schema holds no state, so every call returns the same frozen instance.
udim2
ConstructorCreates a schema that accepts a UDim2.
local Size = v.udim2()
Size:parse(UDim2.fromScale(1, 0.5)) --> UDim2
Size:parse({ X = 1, Y = 0.5 }) --> errors: expected UDim2, got table
The schema holds no state, so every call returns the same frozen instance.
literal
ConstructorCreates a schema that accepts exactly value, compared with ==.
local Consented = v.literal(true)
Consented:parse(true) --> true
Consented:parse(false) --> errors: expected true, got false
Luau widens a literal argument when it infers a generic, so the result is
typed boolean rather than true. Annotate the schema if you need the
singleton: local Kind: LiteralSchema<"player"> = v.literal("player").
uuid
ConstructorCreates a schema that accepts a UUID: 8-4-4-4-12 hexadecimal digits, in either case, carrying a version of 1 to 8 and an RFC 9562 variant.
local Id = v.uuid()
local SessionId = v.uuid({ version = "v7" })
Id:parse("01912d68-783e-7cc0-a2f0-5f8b1d3e9a7c") --> ok
SessionId:parse("6f9619ff-8b86-4d11-b42d-00cf4fc964ff")
--> errors: expected a v7 uuid, got v4
Because the version and variant are checked, the nil UUID and the max UUID are both rejected. Use Vet.guid when you only care about the layout.
The result is a plain StringSchema, so :optional(), :default() and
Vet.infer behave exactly as they do for any other string.
Errors
| Type | Description |
|---|---|
| "unknown uuid version ..." | Thrown when `options.version` is not v4 to v8 |
guid
ConstructorCreates a schema that accepts a GUID: the same 8-4-4-4-12 layout as Vet.uuid, optionally wrapped in braces, with no variant requirement.
local Id = v.guid()
Id:parse("{6F9619FF-8B86-D011-B42D-00CF4FC964FF}") --> ok
Id:parse("6f9619ff-8b86-d011-b42d-00cf4fc964ff") --> ok
This is the shape HttpService:GenerateGUID returns, which is braced and
uppercase by default. Values Microsoft produced do not always follow the
RFC variant rule, so only the layout is enforced unless you pass a version.
Errors
| Type | Description |
|---|---|
| "unknown guid version ..." | Thrown when `options.version` is not v4 to v8 |
array
ConstructorCreates a schema that accepts an array of item.
local Scores = v.array(v.number():integer())
Scores:parse({ 1, 2, 3 }) --> { 1, 2, 3 }
Scores:parse({ 1, 1.5 }) --> errors: [2]: expected an integer, got 1.5
tuple
ConstructorCreates a schema that accepts a fixed sequence, checking each position against its own schema.
local Waypoint = v.tuple({ v.string(), v.number():integer() })
Waypoint:parse({ "spawn", 3 }) --> { "spawn", 3 }
Waypoint:parse({ "spawn" }) --> errors: [2]: expected number, got nil
Luau infers a mixed array literal as a union of its element types, so the
positions are already merged by the time a type function can see them. The
result is therefore { string | number } rather than a true positional
type — the runtime check is exact, the static type is not.
object
ConstructorCreates a schema that validates a table field by field. The field types come from the shape, so the schema is the only place they are declared.
local User = v.object({
name = v.string():min(3),
nickname = v.string():optional(),
})
type User = v.infer<typeof(User)> --> { name: string, nickname: string? }
union
Constructor
Creates a schema that accepts a value matching any one of options, trying
them in order and keeping what the first match parsed to.
local Id = v.union({ v.string():min(1), v.number():integer() })
type Id = v.infer<typeof(Id)> --> string | number
Id:parse("player_1") --> "player_1"
Id:parse(7) --> 7
Id:parse(1.5) --> errors: expected one of 2 options
-- (option 1: expected string, got number;
-- option 2: expected an integer, got 1.5)
Luau infers a mixed array literal as a union of its element types, which is exactly what a union needs, so the inferred output is the union of what each option parses to.
Errors
| Type | Description |
|---|---|
| "expected at least one option" | Thrown when `options` is empty |
readonly
Constructor
Marks a schema read-only, so a field using it is declared read in the
record Vet.object parses to.
local PlayerData = v.object({
id = v.readonly(v.number():integer()),
name = v.string(),
})
type PlayerData = v.infer<typeof(PlayerData)>
--> { read id: number, name: string }
Every schema also has a :readonly() method, which reads better at the end
of a chain: v.number():integer():readonly(). Use this form when the schema
arrives from somewhere else and there is no chain to put it on.
writeonly
Constructor
Marks a schema write-only, so a field using it is declared write in the
record Vet.object parses to.
local PlayerData = v.object({
name = v.string(),
secretToken = v.writeonly(v.string():length(32)),
})
type PlayerData = v.infer<typeof(PlayerData)>
--> { name: string, write secretToken: string }
The token is still validated on the way in and still present at runtime — what changes is that reading it back is an analysis error, so it cannot be logged or replicated by accident.