Skip to content

Composable Schema Validation

When to use

Use this pattern when validating user input — form fields, API payloads, or RPC arguments — and you need a validation layer that composes small reusable validators into complex schemas, supports chaining with .message() overrides and required()/optional() wrappers, returns structured error objects with paths rather than plain strings, and can coerce values as part of the validation pipeline. This pattern uses a schema-fns-style library where validators are functions that return Validator instances, and schemas compose validators into a pipeline.

The pattern

Core primitives

Every validator is a Validator instance. A validator takes a value and either passes (returning the value, possibly transformed) or throws a ValidationError.

javascript
import {
  schema, key, required, optional, type, hasKey, oneOf,
  string, number, items, minLength,
  ValidationError, Validator,
} from "schema-fns";

Building a schema

Compose validators with schema(). Validators run in order.

javascript
const NameSchema = schema(
  required(),
  minLength(3),
);

const result = NameSchema.validate("Alice");
// { valid: true, value: "Alice" }

Object schemas with key()

Use key() to validate individual properties. Each key() receives the property name followed by validators for that property's value.

javascript
const UserSchema = schema(
  type(Object),
  key("name",
    required(),
    type(String),
    string.minLength(1),
    string.maxLength(191),
  ),
  key("email",
    required(),
    string.email(),
  ),
  key("website",
    optional(string.url()),
  ),
);

Required vs. optional fields

required() rejects undefined, null, "", [], and {}. When a required field is missing, nested validators are skipped — you get one RequiredError, not a cascade.

optional() passes undefined, null, and "" without running nested validators. When a value is present, nested validators run normally.

javascript
const ProfileSchema = schema(
  key("bio",
    optional(string.minLength(10)),
  ),
);

ProfileSchema.validate({ bio: undefined });
// { valid: true }

ProfileSchema.validate({ bio: "Hi" });
// { valid: false, errors: [MinimumStringLengthError] }

String validators

Built-in string validators cover common constraints — length, format, and pattern:

javascript
string.minLength(n)  // minimum character count
string.maxLength(n)  // maximum character count
string.email()       // valid email format
string.url()         // valid URL format
string.isoDate()     // valid ISO 8601 date (YYYY-MM-DD)

Number validators

Built-in number validators cover range and type constraints:

javascript
number.min(n)        // >= n
number.max(n)        // <= n
number.finite()      // excludes Infinity
number.integer()     // whole numbers only
number.positive()    // > 0
number.nonNegative() // >= 0

Custom error messages

Override the default message with .message(). Pass a string or a function that receives the original error.

javascript
key("amount",
  required().message("Amount is required"),
  currencyString().message("Amount must be a dollar amount"),
  number.finite().message("Amount must be a finite number"),
)

// Function form — access error details
minLength(3).message(error =>
  `Must be at least ${error.details.minLength} characters`
)

Structural validators

hasKey() checks that a key exists on the object before accessing it. type() checks the constructor. oneOf() restricts to a set of allowed values.

javascript
const AccountSchema = schema(
  type(Object),
  hasKey("name"),
  key("name", required(), type(String)),
  key("type",
    required(),
    oneOf("ASSET", "LIABILITY"),
  ),
);

Collection validation with items()

Validate every element in an array. Errors include the index in the error path.

javascript
const TagListSchema = schema(
  items(string.minLength(1)),
);

TagListSchema.validate(["good", "", "fine"]);
// errors[0].path → [1]

Nested objects

Nest key() calls to validate deeply structured data. Error paths reflect the nesting.

javascript
const AddressSchema = schema(
  key("address",
    key("city", required()),
    key("zip", required(), string.minLength(5)),
  ),
);

AddressSchema.validate({ address: { zip: "12" } });
// errors[0].path → ["address", "city"]
// errors[1].path → ["address", "zip"]

Type coercion with type.to()

Transform values as part of validation. The coerced value appears in the result.

javascript
type.to(Number).validate("42");
// { valid: true, value: 42 }

type.to(Boolean).validate("");
// { valid: true, value: false }

type.to(Date).validate("2021-01-01");
// { valid: true, value: Date instance }

Custom validators

Create domain-specific validators by instantiating Validator directly.

javascript
function currencyString() {
  return new Validator((value) => {
    const parsed = parseCurrency(value);
    if (Number.isNaN(parsed)) {
      throw new ValidationError({
        message: "Must be a valid currency amount",
      });
    }
    return parsed;
  });
}

function nonNegativeNumber() {
  return new Validator((value) => {
    if (typeof value !== "number" || value < 0) {
      throw new ValidationError({
        message: "Must be a non-negative number",
      });
    }
  });
}

Validation methods

Every schema or validator exposes multiple ways to check values:

javascript
const s = schema(required(), string.minLength(3));

// Get a detailed result object
s.validate(value);
// { valid: true, value } or { valid: false, errors: [...] }

// Boolean check
s.test(value);        // true or false

// Throw on invalid
s.assert(value);      // throws ValidationError if invalid

// Async variants for async validators
await s.validateAsync(value);
await s.testAsync(value);
await s.assertAsync(value);

Two-tier schema design

Separate schemas for form UI (friendly messages, lenient parsing) and the RPC layer (strict types, no ambiguity) let you validate the same data at different strictness levels:

javascript
// Strict RPC schema — types enforced, keys required
export const AccountRPCInputSchema = schema(
  type(Object),
  hasKey("name"),
  key("name",
    required(),
    type(String),
    string.minLength(1),
    string.maxLength(191),
  ),
  hasKey("type"),
  key("type", required(), isAcctType()),
  key("institution_id",
    optional(
      type(String),
      string.minLength(0),
      string.maxLength(32),
    ),
  ),
  key("website_link", optional(string.url())),
);

// Friendly form schema — custom messages for the UI
export const AccountFormInputSchema = schema(
  key("name",
    required().message("Account Name is required."),
    string.maxLength(191).message(
      "Account Name cannot exceed 191 characters"
    ),
  ),
  key("type",
    required().message("Type is required"),
    isAcctType().message("Type must be Asset or Liability"),
  ),
  key("institution_id",
    optional(
      string.maxLength(32).message(
        "Institution ID cannot exceed 32 characters"
      ),
    ),
  ),
  key("website_link",
    optional(
      string.url("Website Link doesn't look like a URL"),
    ),
  ),
);

Cross-field validation

Validators that compare two fields enforce business rules at the schema level. datesInOrder() and increasingOrEqual() each receive two key names and validate the relationship between their values:

javascript
const GoalFormInputSchema = schema(
  key("start_amount",
    whenEmpty("0.00"),
    currencyString().message(
      "Starting amount must be a dollar amount"
    ),
    nonNegativeNumber().message(
      "Starting amount must be a positive number or zero"
    ),
  ),
  key("target_amount",
    required().message("Target amount is required"),
    currencyString().message(
      "Target amount must be a dollar amount"
    ),
    positiveNumber().message(
      "Target amount must be a positive number"
    ),
  ),
  datesInOrder("start_date", "target_date").message(
    "Target date must be after start date"
  ),
  increasingOrEqual("start_amount", "target_amount").message(
    "Target amount must be greater than starting amount"
  ),
);

Inspecting validation results

Validation errors carry a path array that maps directly to form field names, making it straightforward to display errors next to the relevant input:

javascript
const result = AccountFormInputSchema.validate(formData);

if (!result.valid) {
  for (const error of result.errors) {
    console.log(error.path.join("."), error.message);
    // "name" → "Account Name is required."
    // "type" → "Type must be Asset or Liability"
  }
}

Trade-offs

  • Composability over magic. Each validator does one thing. Complex rules are built by combining simple pieces. This makes schemas readable but verbose compared to declarative DSLs like JSON Schema.
  • Two-tier schemas add maintenance cost. Having separate form and RPC schemas means updating validation rules in two places. The benefit is user-facing messages that are independent of internal constraints.
  • Synchronous by default, async when needed. Most validators are synchronous. The async path (validateAsync, testAsync, assertAsync) exists for validators that hit databases or external services, but mixing sync and async validators in the same schema requires using the async methods throughout.
  • Error paths enable UI mapping. Validation errors carry a path array (e.g., ["address", "city"]) that maps directly to form field names. This is more useful than a flat error list but requires your UI to interpret paths.
  • Custom validators are trivial to write. Any function that throws a ValidationError on failure can be a validator. This keeps the core library small while supporting domain-specific rules like currencyString() or isAcctType().
  • No schema introspection. Unlike JSON Schema or Zod, composable validators don't produce a serializable schema definition. You cannot generate documentation or client validation from the schema object itself.