Skip to main content

Vet

Lua-first schema validation with type inference

Vet

Lua-first schema validation with type inference

local v = require(ReplicatedStorage.Packages.Vet)

local Profile = v.object({
    Tagline = v.string():min(3):max(22):optional(),
    Level = v.number():integer():min(1):max(100):default(1),
    Money = v.number():integer():min(0):max(100000):default(0),
})

-- An optional field may be missing. A field with a default gets filled in.
Profile:parse({})
--> { Level = 1, Money = 0 }

Profile:parse({ Tagline = "kyle" })
--> { Tagline = "kyle", Level = 1, Money = 0 }

Profile:parse({ Tagline = "kyle", Level = 42 })
--> { Tagline = "kyle", Level = 42, Money = 0 }

-- Optional is not unchecked: a value that is present still has to be valid.
-- parse throws, and the message names the field that failed.
Profile:parse({ Tagline = "ky" })
--> errors: Tagline: expected at least 3 characters, got 2

-- Every field is checked, so one call reports everything that is wrong.
Profile:parse({ Tagline = "ky", Level = 900 })
--> errors: Tagline: expected at least 3 characters, got 2;
--          Level: expected at most 100, got 900

The schema is the type too, so the fields are written down only once:

type Profile = v.infer<typeof(Profile)>
--> { Tagline: string?, Level: number, Money: number }

Compared to t

t is the established runtime type checker for Roblox, and it is a good library. The difference is not features first — it is what a schema is for.

t checks. Vet parses. A t checker is a predicate, (value) -> (boolean, string?). It answers whether a value is acceptable and then gets out of the way:

local checkProfile = t.interface({
    Tagline = t.optional(t.string),
    Level = t.numberConstrained(1, 100),
})

if checkProfile(data) then
    -- `data` is the same untyped table you started with
end

A Vet schema hands back the validated value, which is what makes defaults and type inference possible at all:

local profile = Profile:parse(data)
--> Level and Money filled in, typed as { Tagline: string?, Level: number, ... }

That one difference is where the rest of it comes from. Because parsing returns a value it can fill defaults, so the schema doubles as the default document for ProfileStore or DocumentService. Because it returns a typed value, v.infer can derive the Luau type from the same declaration instead of you writing it twice.

tVet
ShapePredicate returning boolean, string?Parser returning the validated value
Luau typeWritten separately by handDerived with v.infer
Defaults—:default(v) and :defaultWith(f)
ErrorsFirst failure, one stringEvery failure, each with a path like members[1].level
Introspection—shape, minimum, maximum, isOptional
Roblox datatypesAll of themcolor3, udim2 so far
InstancesinstanceOf, instanceIsA, children—
Unions, maps, patternsunion, intersection, map, keys, values, matchunion
Requires the new type solverNoYes, for v.infer

When t is the better choice

  • You want breadth today. t covers every Roblox datatype, Instances, Enums, maps, intersections and string patterns. Vet has union, but none of the other three yet.
  • You are checking function arguments. t.wrap and t.tuple are built for it, and a predicate is the right shape when there is no value to hand back.
  • You cannot enable the new type solver. v.infer is a user-defined type function, so it needs a Luau version that supports them. Everything else in Vet works without it, but the headline feature does not.
  • You want the smaller, settled dependency. t is widely used and has been stable for years.

When Vet is the better choice

  • The data is stored, not just checked. Defaults mean the schema is also the template, and one declaration stops the template, the validator and the type from drifting apart.
  • You want the type for free. v.infer derives it from the schema.
  • You need to know what failed, not just that something did. Issue paths point at members[1].level rather than handing you one string about the outermost table.
  • Something else reads the schema. Profile.shape.Level.minimum can drive a slider range or an input limit without restating the bound.