Dynz is a headless schema library that replaces scattered form logic with a single TypeScript definition any consumer can evaluate — a Frontend, a Backend, an LLM, or an external system. Conditions, validation rules, and derived values are expressed as data, not code, making schemas portable, testable, and channel-agnostic by design.
- Headless by design — no UI components, no rendering opinions. Bring your own renderer and wire it to the schema.
- Serializable expression system — conditions, validation rules, and derived values are data structures, not JavaScript functions. Schemas survive a JSON round-trip and can be evaluated in any runtime.
- Cross-field expressions — d.ref(), d.multiply(), d.add() and friends compose freely inside any rule slot, making inter-field dependencies first-class rather than bolted on via refine.
- Runtime schema generation — schemas are plain TypeScript objects, designed to be generated by a NestJS service at request time based on user context, product state, or business rules.
- Universal consumer support — the same schema drives a web UI, a mobile app, an AI agent via MCP, or a third-party integration without modification.
npm install dynz
# or
pnpm add dynz
# or
yarn add dynzimport * as d from "dynz";
// Define a schema
const userSchema = d.object({
name: d.string().min(2),
email: d.string().email(),
password: d.string().min(6),
passwordConfirm: d.string().equals(d.ref("password")),
});
// Send it to the external system (e.g. frontend)
return Response.json(userSchema);
// Get the response from the request
const schema = await response.json();
// Validate the response
const result = d.validate(schema, undefined, {
name: "John Doe",
email: "john@example.com",
password: "MyPassword",
passwordConfirm: "MyPassword",
});
if (result.success) {
console.log("Valid data:", result.values);
} else {
console.log("Validation errors:", result.errors);
}dynz supports comprehensive schema types for all common data structures:
import * as d from "dynz";
// String schema with validation rules
const nameSchema = d.string().min(2).max(5).regex("^[a-zA-Z\\s]+$");
// Number schema with constraints
const ageSchema = d.number().min(0).max(120);
// Object schema with nested fields
const userSchema = d.object({
profile: d.object({
name: d.string().min(1),
bio: d.string().optional(),
}),
});
// Array schema
const tagsSchema = d.array(d.string().min(1)).min(5).max(20);Extensive validation rules for precise data validation:
import * as d from "dynz";
const productSchema = d.object({
name: d.string().min(1).max(100),
email: d.string().email(),
price: d.number().min(0),
category: d.options(["electronics", "books", "clothing"]),
sku: d.string().regex("^[A-Z]{3}-\\d{4}$"),
quantity: d.string().isNumeric(),
});dynz excels at dynamic validation based on other field values:
import * as d from "dynz";
const userSchema = d.object({
accountType: d.options(["personal", "business"]),
industry: d.options(["finance", "healthcare", "tech"]),
// Included only for business accounts
companyName: d
.string()
.min(2)
.setIncluded(d.eq(d.ref("accountType"), "business")),
// Different validation rules based on account type
email: d
.string()
.email()
.when(d.eq(d.ref("accountType"), "business"), (b) =>
b.regex(
"@company\\.com$",
undefined,
"Business accounts must use company email",
),
),
// Complex conditional logic
specialField: d
.string()
.setRequired(
d.and(
d.eq(d.ref("accountType"), "business"),
d.or(
d.eq(d.ref("industry"), "finance"),
d.eq(d.ref("industry"), "healthcare"),
),
),
),
});Reference other fields in validation rules:
import * as d from "dynz";
const signupSchema = d.object({
password: d.string().min(8),
confirmPassword: d.string().equals(d.ref("password"), "Passwords must match"),
birthYear: d
.date()
.after(new Date(new Date().setFullYear(new Date().getFullYear() - 18))),
graduatedAt: d
.date()
.after(d.ref("birthYear"), "Graduation date must be after birth year"),
});Control when fields can be modified based on conditions:
import * as d from "dynz";
function buildSchema(user: { role: "admin" | "user" }) {
return d.object({
status: d.options(["draft", "published"]),
// mutability as a constant, but dynamically generated
title: d
.string()
.min(1)
.setMutable(user.role === "admin"),
// mutability as a predicate; resolved in the schema itself
content: d.string().setMutable(d.eq(d.ref("status"), "draft")),
// mutability as a constant
createdAt: d.string().setMutable(false),
});
}You can also control mutability on inner schemas of an array. This allows you to add new elements or remove elements, but not to mutate elements on the same index.
import * as d from "dynz";
// The array is mutable, but each element is immutable
const schema = d.array(d.string().setMutable(false));
d.validate(schema, [], ["foo"]); // Validates successfully, because it's a new entry
d.validate(schema, ["foo"], []); // Validates successfully, because an entry is removed
d.validate(schema, ["foo"], ["bar"]); // Returns an error since 'foo' is mutated into 'bar'Dynamically include or exclude fields:
import * as d from "dynz";
const registrationSchema = d.object({
dietryRestrictions: d.boolean(),
dietryDetail: d.string().setIncluded(d.eq(d.ref("dietryRestrictions"), true)),
});.setDefault(value) fills a field in whenever it's left empty (undefined or null) — on any schema type, including object() and array().
import * as d from "dynz";
const settingsSchema = d.object({
theme: d.options(["light", "dark"] as const).setDefault("light"),
notifications: d.boolean().setDefault(true),
});
await d.validate(settingsSchema, undefined, {});
// { success: true, values: { theme: "light", notifications: true } }A default satisfies required — a required field left empty validates successfully with
the default in the output, instead of failing:
const schema = d.string().setRequired(true).setDefault("Anonymous");
await d.validate(schema, undefined, undefined);
// { success: true, values: "Anonymous" }Rules still apply to a default, the same way they apply to a supplied value. A default doesn't bypass validation — it's checked against the field's own rules like anything else, so an invalid default surfaces as a real error rather than being silently accepted:
const schema = d.string().min(5).setDefault("hi"); // "hi" is shorter than min(5)
await d.validate(schema, undefined, undefined);
// { success: false, errors: [{ code: "min_length", path: "$", ... }] }ref() still works against a defaulted field. When a field is left empty, a
sibling's ref() to it resolves to the exact same value that field's own validation
produces — the two can never disagree:
const schema = d.object({
plan: d.options(["free", "pro"] as const).setDefault("free"),
companyName: d.string().setRequired(d.eq(d.ref("plan"), "free")),
});
await d.validate(schema, undefined, { companyName: "Acme" });
// { success: true, values: { plan: "free", companyName: "Acme" } }A default on object()/array() only fires when the object or array itself is
entirely absent (undefined/null) — not when it's present but partial, and not
when it's an explicitly-submitted empty object or array. Once it fires, the
substituted value is handed to completely ordinary validation, exactly as if it had
been submitted that way: every field's own default still gets applied independently for
whatever the object's default doesn't mention.
const profileSchema = d
.object({
displayName: d.string().setDefault("New user"),
bio: d.string(),
})
.setDefault({});
// The whole object is missing -> its default ({}) fires, then normal validation runs
// on it: `bio` (no default, required) still fails, `displayName` fills itself in.
await d.validate(profileSchema, undefined, undefined);
// { success: false, errors: [{ path: "$.bio", code: "required", ... }] }.setDefault({}) is a common, useful pattern precisely because of this: it materializes
the object so each field's own default gets a chance to run, without needing to repeat
every field's fallback value in the object's own literal.
const profileSchema = d
.object({
displayName: d.string().setDefault("New user"),
bio: d.string().setDefault(""),
})
.setDefault({});
await d.validate(profileSchema, undefined, undefined);
// { success: true, values: { displayName: "New user", bio: "" } }If you want a field to always have a sensible fallback — whether its parent object is
missing entirely or just doesn't happen to include that field — give the field its own
.setDefault(...) rather than relying on the parent's. A parent's default is not merged
into a partially-submitted object; it only ever describes what happens when the parent
itself is missing.
A Date nested inside a default — at any depth — survives a schema that's gone through
serialize() and JSON.parse() (e.g. shipped to a browser as JSON and validated there);
it's coerced back into a real Date wherever it's used.
discriminatedUnion() follows the exact same rule: a default only fires when the union
itself is entirely absent, and the discriminator key is required in the default value —
it's what picks which member applies, so there's no other way to know which member's
shape the rest of the default should match.
const contactSchema = d
.discriminatedUnion("type", [
{ type: "email", email: d.string() },
{ type: "phone", phone: d.string() },
])
.setDefault({ type: "email", email: "hello@example.com" });
await d.validate(contactSchema, undefined, undefined);
// { success: true, values: { type: "email", email: "hello@example.com" } }Just like an object default, a union default only needs to set the discriminator — every other field of that member falls back to its own default, if it has one:
const contactSchema = d
.discriminatedUnion("type", [
{ type: "email", email: d.string().setDefault("hello@example.com") },
{ type: "phone", phone: d.string() },
])
.setDefault({ type: "email" });
await d.validate(contactSchema, undefined, undefined);
// { success: true, values: { type: "email", email: "hello@example.com" } }ref() resolves consistently here too, including into the discriminator itself:
const schema = d.object({
contact: d
.discriminatedUnion("type", [
{ type: "email", email: d.string() },
{ type: "phone", phone: d.string() },
])
.setDefault({ type: "email", email: "hello@example.com" }),
note: d.string().setRequired(d.eq(d.ref("contact.type"), "email")),
});
await d.validate(schema, undefined, { note: "hi" });
// { success: true, values: { contact: { type: "email", email: "hello@example.com" }, note: "hi" } }Create reusable custom validation logic:
import * as d from "dynz";
const passwordStrengthRule = d.custom("passwordStrength", {
minScore: 4,
requireSpecialChars: true,
});
const passwordStrengthRuleValidator: d.CustomRuleFunction = (value, params) => {
// ... validation logic
};
// Use custom() to add arbitrary rules to a fluent chain
const strongPasswordSchema = d
.string()
.min(8)
.custom("passwordStrength", { minScore: 4, requireSpecialChars: true });
d.validate(strongPasswordSchema, undefined, "myStrongPassword", {
customRules: {
passwordStrength: passwordStrengthRuleValidator,
},
});import * as d from "dynz";
const orderSchema = d.object({
orderType: d.options(["standard", "express", "international"]),
shippingMethod: d
.options(["overnight", "same-day", "air", "sea"])
.when(d.eq(d.ref("orderType"), "express"), (b) =>
b.oneOf(["overnight", "same-day"]),
)
.when(d.eq(d.ref("orderType"), "international"), (b) =>
b.oneOf(["air", "sea"]),
)
.setRequired(
d.or(
d.eq(d.ref("orderType"), "express"),
d.eq(d.ref("orderType"), "international"),
),
),
customsInfo: d
.object({
value: d.number().min(0),
description: d.string().min(1),
})
.setIncluded(d.eq(d.ref("orderType"), "international")),
});import * as d from "dynz";
// Order management with status-based mutability
const orderSchema = d.object({
orderStatus: d.options(["draft", "pending", "send"]).setMutable(false), // always immutable
items: d
.array(d.string())
.setMutable(
d.or(
d.eq(d.ref("orderStatus"), "draft"),
d.eq(d.ref("orderStatus"), "pending"),
),
),
shippingAddress: d
.string()
.setMutable(
d.or(
d.eq(d.ref("orderStatus"), "draft"),
d.eq(d.ref("orderStatus"), "pending"),
),
),
});string()- String validation schema with fluent methodsnumber()- Number validation schema with fluent methodsboolean()- Boolean validation schema with fluent methodsobject(fields)- Object schema with nested field schemasarray(schema)- Array schema with item validationdate()- Date validation schema with fluent methodsoptions(values)- Enum-like validation for predefined valuesfile()- File validation schema with fluent methods
String schemas:
.min(value, code?)- Minimum string length.max(value, code?)- Maximum string length.email(code?)- Email format validation.regex(pattern, flags?, code?)- Regular expression validation.equals(value, code?)- Exact value matching.oneOf(values, code?)- Must be one of specified values.isNumeric(code?)- Numeric string validation
Number schemas:
.min(value, code?)- Minimum numeric value.max(value, code?)- Maximum numeric value.maxPrecision(value, code?)- Maximum decimal precision
Date schemas:
.before(value, code?)- Date must be before value.after(value, code?)- Date must be after value.minDate(value, code?)- Minimum date (inclusive).maxDate(value, code?)- Maximum date (inclusive)
Array schemas:
.min(value, code?)- Minimum array length.max(value, code?)- Maximum array length
.setRequired(value)- Mark as required (boolean or predicate).optional()- Shorthand for.setRequired(false).setMutable(value)- Control field mutability (boolean or predicate).setIncluded(value)- Control field inclusion (boolean or predicate).setPrivate(value)- Mark as private/masked.setCoerce(value)- Enable automatic type coercion.setDefault(value)- Set default value
.when(predicate, callback)- Apply rules conditionally
Predicates return a boolean and are used wherever a schema accepts a conditional expression: .setRequired(), .setMutable(), .setIncluded(), and .when().
Comparison:
eq(left, right)- Equals (===)neq(left, right)- Not equals (!==)gt(left, right)- Greater than (>)gte(left, right)- Greater than or equal (>=)lt(left, right)- Less than (<)lte(left, right)- Less than or equal (<=)matches(value, pattern, flags?)- Regex pattern matching
Collection:
isIn(value, array)- Value is contained in arrayisNotIn(value, array)- Value is not contained in array
Logical combinators:
and(...predicates)- All predicates must be trueor(...predicates)- At least one predicate must be true
Transformers compute a value and can be used as an input to any predicate or validation rule.
Arithmetic:
sum(...values)- Add values togethersub(...values)- Subtract values left to rightmultiply(...values)- Multiply values togetherdivide(...values)- Divide values left to rightmin(...values)- Smallest of the given valuesmax(...values)- Largest of the given values
Math:
ceil(value)- Round up to nearest integerfloor(value)- Round down to nearest integersin(value)- Sinecos(value)- Cosinetan(value)- Tangent
Utility:
age(dateValue)- Age in years derived from a date valuesize(value)- Length of a string or array, size in bytes of a filelookup(value, table)- Map a value through a lookup table
ref(path)- Reference another field's valuev(value)- Wrap a static/constant value
validate(schema, currentValues?, newValues, options?)- Main validation functionvalidateMutableoption - Check field mutability constraints (defaults to true)customRulesoption - Provide custom rule implementations
dynz provides excellent TypeScript integration:
import * as d from "dynz";
const schema = d.object({
name: d.string().min(1),
age: d.number().optional(),
tags: d.array(d.string()),
});
// Inferred type: { name: string; age?: number; tags: string[] }
type UserData = d.SchemaValues<typeof schema>;
// Type-safe validation results
const result = d.validate(schema, undefined, {
name: "John",
tags: ["dynz"],
});
if (result.success) {
// result.values is properly typed as UserData
console.log(result.values.name); // ✅ Type-safe access
}Check out the /examples directory for complete working examples:
- Next.js Example - React forms with dynz schemas
import * as d from "dynz";
const loanApplicationSchema = d.object({
applicantType: d.options(["individual", "business"]),
income: d
.number()
.min(0)
.setIncluded(d.eq(d.ref("applicantType"), "individual")),
businessRevenue: d
.number()
.min(0)
.setIncluded(d.eq(d.ref("applicantType"), "business")),
loanAmount: d
.number()
.min(1000)
// Can't exceed annual income for individuals
.when(d.eq(d.ref("applicantType"), "individual"), (b) =>
b.max(d.ref("income")),
)
// Can't exceed annual revenue for businesses
.when(d.eq(d.ref("applicantType"), "business"), (b) =>
b.max(d.ref("businessRevenue")),
),
});import * as d from "dynz";
const documentWorkflowSchema = d.object({
content: d
.string()
.setMutable(
d.and(
d.eq(d.ref("status"), "draft"),
d.or(
d.eq(d.ref("isOwner"), true),
d.eq(d.ref("hasEditPermission"), true),
),
),
),
status: d
.options(["draft", "review", "approved", "published"])
.setMutable(
d.or(
d.and(
d.eq(d.ref("currentStatus"), "draft"),
d.eq(d.ref("isOwner"), true),
),
d.and(
d.eq(d.ref("currentStatus"), "review"),
d.eq(d.ref("userRole"), "reviewer"),
),
d.eq(d.ref("userRole"), "admin"),
),
),
});We welcome contributions! Please see our Contributing Guide for details.
MIT © dynz
- More framework integrations coming soon...
Built with ❤️ for type-safe validation