[Release] SafeValue — Fluent Value validation for Roblox Luau

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

  1. Drop SafeValue.luau into a ModuleScript anywhere in your project (e.g., ReplicatedStorage.Modules).
  2. 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 errors array covering every missing or invalid field.
  • Reuse the schema by sharing profileSchema wherever the same structure appears (datastore loads, remote events, etc.).
  • Mix and match any other SafeValue logic 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 Promise libraries 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.

9 Likes

If anyone’s had a chance to try out SafeValue, I’d love to hear your feedback! :grinning_face:

Amazing implementation of a schema validation library, haven’t tested it out yet but looks amazing from the looks of it’s documentation.

Was hoping for something like this on Roblox after using Zod for web-development.

1 Like