Getting Started
Vet validates unknown data against a schema and gives you back a typed value. Define the shape once, and it serves as both the runtime check and the type.
Installation
Add Vet to your wally.toml:
[dependencies]
Vet = "imkylecat/vet@0.1.0"
Then run wally install.
Your first schema
local v = require(ReplicatedStorage.Packages.Vet)
local Username = v.string():min(3):max(22)
Username:parse("kyle") --> "kyle"
Username:parse("ky") --> errors: expected at least 3 characters, got 2
Every schema starts from a constructor on v and is narrowed by chaining
refinements. Refinements return a new schema, so a base schema is safe to
share:
local Base = v.string()
local Short = Base:max(10)
local Long = Base:min(50)
-- Base itself stays unconstrained
parse and safeParse
parse returns the value and throws when validation fails. Use it when a
failure is a bug worth surfacing.
safeParse never throws. Use it for data you do not control — remote
arguments, DataStore reads, HTTP responses:
local result = Username:safeParse(input)
if result.success then
print(result.value) -- typed as string
else
for _, issue in result.issues do
warn(issue.message)
end
end
Luau narrows on result.success, so result.value is only reachable once you
have checked it.
Every refinement runs before the result is returned, so issues describes each
failure rather than stopping at the first.
parse throws, so it is the assertion if you want one. It is named for what it
returns rather than what it raises — see why parse and not assert
for the reasoning.
Inferring types
v.infer extracts the type a schema parses to, so the schema stays the only
place your field types are written down:
local User = v.object({
name = v.string():min(3),
nickname = v.string():optional(),
})
type User = v.infer<typeof(User)>
--> { name: string, nickname: string? }
It works on every schema, including nested objects and arrays.
v.infer is a user-defined type function, so it runs during analysis and
costs nothing at runtime. It needs a Luau version that supports user-defined
type functions — in editors that means enabling the new type solver.
Read-only and write-only fields
Chaining :readonly() or :writeonly() puts a Luau property access modifier on
the field the schema describes, so the inferred type also says who may touch it:
local PlayerData = v.object({
id = v.number():integer():readonly(),
name = v.string(),
secretToken = v.string():length(32):writeonly(),
})
type PlayerData = v.infer<typeof(PlayerData)>
--> { read id: number, name: string, write secretToken: string }
Both come last in a chain, after the refinements and after :optional() or
:default(). Neither changes what is accepted — secretToken is still required
and still has to be 32 characters, and every value is still there at runtime.
What changes is the type, so reassigning an id or reading a token back out is a
mistake the analyser catches.
Use v.readonly(schema) and v.writeonly(schema) for a schema that arrived
from somewhere else and has no chain to sit on. Either form leaves the marker
readable as access, next to minimum, maximum and isOptional:
PlayerData.shape.id.access --> "read"
PlayerData.shape.name.access --> "readwrite"
PlayerData.shape.secretToken.access --> "write"