Using Vet with DocumentService
DocumentService already expects
a schema. Every DocumentStore takes a check function that it calls before it
trusts anything it loaded, and it refuses to open a document whose data does not
pass. That is not a place to bolt validation on — it is a hole shaped exactly
like a Vet schema.
check is the integration point
check has the signature (unknown) -> (boolean, T): given whatever came back
from the DataStore, say whether it is valid and hand back the typed value. Vet's
safeParse returns the same two things, so the adapter is a few lines:
local v = require(ReplicatedStorage.Packages.Vet)
local DataSchema = v.object({
Coins = v.number():integer():min(0):default(100),
Level = v.number():integer():min(1):max(100):default(1),
Tagline = v.string():min(3):max(22):optional(),
})
local function check(value: unknown)
local result = DataSchema:safeParse(value)
if result.success then
return true, result.value
end
for _, issue in result.issues do
warn(`document invalid at {issue.path[1]}: {issue.message}`)
end
return false, nil
end
The warn loop matters. check can only answer yes or no, so the reason is
lost the moment you return false — DocumentService reports the failure as
"CheckError" and nothing about which field was wrong. Logging the issues first
is the difference between "a document failed to open" and "Coins was -40".
The default is the same schema
Parse nothing to get the starting document:
local DEFAULT = DataSchema:parse({})
--> { Coins = 100, Level = 1 }
Tagline is absent rather than nil-valued, because an optional field that was
not supplied is simply not there. Every other field is filled from its default,
and a field with neither a default nor :optional() makes this call throw —
which is what you want, since a default document cannot have a missing field.
A store
local DataStoreService = game:GetService("DataStoreService")
local ServerScriptService = game:GetService("ServerScriptService")
local DocumentStore = require(ServerScriptService.DocumentService.DocumentStore)
type PlayerData = v.infer<typeof(DataSchema)>
local store = DocumentStore.new({
dataStore = DataStoreService:GetDataStore("PlayerData") :: any,
check = check,
default = DataSchema:parse({}),
migrations = {},
lockSessions = true,
})
Opening follows DocumentService's own conventions — the result carries
success and, on failure, a reason:
local document = store:GetDocument(`{player.UserId}`)
local result = document:Open()
if not result.success and result.reason == "SessionLockedError" then
document:Steal()
result = document:Open()
end
if not result.success then
player:Kick(`Could not load your data: {result.reason}`)
return
end
print(document:GetCache().Coins) -- typed as number
Of the reasons DocumentService can give — "RobloxAPIError",
"SessionLockedError", "CheckError", "BackwardsCompatibilityError" and
"SchemaError" — only "CheckError" comes from your schema, and the warnings
from check are already in the log saying why.
Validating on the way in, not just out
check guards what you load. It does not guard what you write: the cache is
yours to set, and a bad value goes to the DataStore without complaint. You find
out on the next load, in a different session, with no idea which line put it
there.
Update takes a Transform<T>, which is (data: T) -> T — a pure function
returning the next state. Parsing on the way out closes the loop:
local result = document:Update(function(data)
local next = table.clone(data)
next.Coins = data.Coins + reward
return DataSchema:parse(next)
end)
If reward was negative, or a bug pushed Coins past its cap, this throws
inside the transform instead of persisting. The same call works on
OpenAndUpdate, and on a plain SetCache:
document:SetCache(DataSchema:parse(next))
This suits DocumentService's immutable cache rather than fighting it. A value that already satisfies its schema comes back as the same table — nothing is copied and nothing is modified — and a copy is made only when a default fills a field in, leaving the original untouched.
Migrations
A migration is an entry in an ordered array:
export type Migrations = {
{
backwardsCompatible: boolean,
migrate: (data: any) -> any,
}
}
migrate receives any and returns any, so nothing checks that it produced
what the next version expects. Keeping a schema per version makes it check
itself — parse the result and the migration cannot quietly emit the wrong
shape:
local V1 = v.object({
Coins = v.number():integer():min(0):default(100),
})
local V2 = v.object({
Coins = v.number():integer():min(0):default(100),
Level = v.number():integer():min(1):max(100):default(1),
})
local migrations = {
{
backwardsCompatible = true,
migrate = function(data)
local previous = V1:parse(data)
return V2:parse({
Coins = previous.Coins,
-- Level is filled from its default
})
end,
},
}
Parsing the input as well as the output is worth the extra line: it fails inside
the migration you are writing, naming the field, rather than as a CheckError
on a document you cannot see. Point check at the newest version and keep the
older schemas around — they are the record of what each migration was migrating
from.
Documents nobody owns
Session locking makes one server responsible for a document at a time. Turn it
off — lockSessions = false, for a shared house, a guild bank, a global
leaderboard — and several servers write to the same key through Update
transactions.
Validation matters more there, not less. With a session lock, a bad value is one player's problem and one server wrote it. Without one, any server can write, and the next transform starts from whatever the last one left. Parsing inside the transform keeps a bad state from becoming the base for every write after it.
Typing the data
v.infer gives you the type DocumentService is generic over:
type PlayerData = v.infer<typeof(DataSchema)>
--> { Coins: number, Level: number, Tagline: string? }
GetCache returns that type, so data.Coins is a number and data.Tagline is
a string? you have to handle.
What this replaces
DocumentService's own guide writes check by hand, asserting the type of each
field and wrapping it with a helper:
local function dataCheck(value: unknown): DataSchema
assert(type(value) == "table", "Data must be a table")
-- one assert per field, by hand
end
That works, and it is three things to keep in step: the assert chain, the
default document, and the type. With a schema the shape is written once and all
three come from it — plus the parts an assert chain usually skips, like
ranges, string lengths, optionality, and a message naming the field rather than
the first assert that fired.