TL;DR
- Validate values once and re-use the same logic everywhere.
- Chain readable rules like
:Type({"number"}),:Range(0, 200), and:Pattern("^%w+$"). - Fail gracefully by returning sanitized defaults and per-field error messages.
- Validate many records at once with
SafeValue.ValidateAll().
What is SafeValue?
SafeValue turns any raw Luau value into a fluent, chainable validator. You wrap a value, describe the constraints you care about, and SafeValue handles the heavy lifting—returning either the original value when it passes or a fallback plus descriptive errors when it fails. This keeps validation logic tidy, testable, and easy to share between scripts.
Why you should use it
- Centralized hygiene: stop scattering
if type(value) ~= ...checks everywhere. - Self-documenting pipelines: chained methods read like a spec for your data.
- Better UX: ship friendly error messages instead of silent failures.
- Batch workflows: collect sanitation results for multiple values in one table.
Installation
- Drop
SafeValue.luauinto a ModuleScript anywhere in your project (e.g.,ReplicatedStorage.Modules). - Require it from scripts that need validation:
local SafeValue = require(game.ReplicatedStorage.Modules.SafeValue)
Quick start
local SafeValue = require(game.ReplicatedStorage.Modules.SafeValue)
local health = SafeValue(100, 50, "Health")
:Type({"number"})
:Min(0)
:Max(200)
local sanitized, isValid, errors = health:Validate()
if not isValid then
warn("Health failed validation", errors)
end
When validation fails, SafeValue automatically returns your fallback (here 50) and the most recent error messages.
Validator reference
Each method overrides the previous validator of the same type, so just call the ones you need:
Type(Types: {string})— Allow a list of accepted Luau types (e.g."string","number"). The value passes as long as it matches any entry in the list.Min(minimum: number)— Numeric value>= minimum. Strings/tables must have length>= minimum.Max(maximum: number)— Numeric value<= maximum. Strings/tables must have length<= maximum.Range(minimum: number, maximum: number)— Value size within[minimum, maximum]; rejects values without a size.Pattern(pattern: string)— Accept strings that match the provided Lua pattern.Enum(values: {any})— Whitelist set membership.Custom(predicate: (any) -> boolean)— Run a custom check; return true when valid.InstanceOf(class: {})— Ensure the value shares a metatable/prototype chain with the given class.HasKey(key: string)— Table must contain the key with a non-nil entry.HasKeys(keys: {string})— Table must contain every key in the list.Schema(schema: {[string]: (any) -> boolean})— Table must satisfy a per-key validator schema.
Batch validation
If you need to validate multiple values in one go, wrap each one and pass them to SafeValue.ValidateAll:
local characterName = SafeValue("Builderman", "Player", "CharacterName")
:Type({"string"})
:Pattern("^%a[%w_]+$")
local currency = SafeValue(750, 0, "Currency")
:Type({"number"})
:Min(0)
local results, allValid = SafeValue.ValidateAll({characterName, currency})
if not allValid then
for name, data in pairs(results) do
if not data.IsValid then
warn(name .. " failed:")
for _, err in ipairs(data.Errors) do
warn(" - " .. err)
end
end
end
end
results is keyed by each validator’s name, and Value holds the sanitized value that you should keep using.
Schema example
Schema() lets you stitch together per-field validators, which is handy when validating configuration tables or remote payloads in one pass.
-- Describe the per-key rules once
local profileSchema = {
id = function(value)
local _, isValid = SafeValue(value)
:Type({"string"})
:Min(1)
:Validate()
return isValid
end,
level = function(value)
local _, isValid = SafeValue(value)
:Type({"number"})
:Min(1)
:Validate()
return isValid
end,
}
local Data = {
id = "abc123",
level = 6,
}
-- Apply the schema to an incoming record
local profileValidator = SafeValue(Data, nil, "Profile")
:Type({"table"})
:Schema(profileSchema)
local sanitizedProfile, isValid, errors = profileValidator:Validate()
- All rules run together so you get one
errorsarray covering every missing or invalid field. - Reuse the schema by sharing
profileSchemawherever the same structure appears (datastore loads, remote events, etc.). - Mix and match any other
SafeValuelogic inside each field function, including nested schemas.
Custom validator example
The :Custom() rule shines when you need cross-field checks or support for Roblox datatypes that built-in validators can’t introspect (e.g. Vector3, CFrame, or complex tables). Use it to keep remote payloads honest before they reach sensitive game logic.
Use case: keeping spawn points inside safe bounds
local spawnZone = {
min = Vector3.new(-512, 10, -512),
max = Vector3.new(512, 150, 512),
}
local spawnLocation = SafeValue(PlayerPosition, "SpawnLocation")
:Custom(function(value)
-- Only accept true Vector3 values that land within the allowed play area
if typeof(value) ~= "Vector3" then
return false
end
return value.X >= spawnZone.min.X and value.X <= spawnZone.max.X
and value.Y >= spawnZone.min.Y and value.Y <= spawnZone.max.Y
and value.Z >= spawnZone.min.Z and value.Z <= spawnZone.max.Z
end)
local sanitizedLocation, isValid = spawnLocation:Validate()
if not isValid then
warn("Spawn outside safe zone, defaulting to lobby pad")
end
Built-in validators can’t express “Vector3 inside this bounding box”—Type() only sees userdata, and Range() works on scalars or lengths. A custom rule gives you full control while still producing a sanitized fallback for suspicious input.
Error handling tips
- Pull the latest errors with
GetErrors()if you need to display them later. - Wrap messaging logic in helper functions to localize or format Error output for your game.
- Combine with Roblox
Promiselibraries to reject invalid payloads early.
Links
Thanks for reading! Let me know if you find any bugs or what else you’d like to see.