Appearance
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() // >= 0Custom 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
patharray (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
ValidationErroron failure can be a validator. This keeps the core library small while supporting domain-specific rules likecurrencyString()orisAcctType(). - 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.