Skip to main content

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"