One schema for types
@cleverbrush/schema infers TypeScript types at compile time, validates untrusted data at runtime, and exposes typed descriptors that power forms, mappers, OpenAPI, and your own tooling.
Get started in seconds
Install only what you need — each package is independent and tree-shakeable.
What makes it different
If you know Zod, you already know most of the API — the primitives, the fluent builder style, and InferType all work the same way. @cleverbrush/schema goes further with three capabilities no other schema library offers.
Typed field-error selectors
Access per-field errors through a typed lambda — no magic strings, no brittle path navigation. TypeScript catches a misspelled field name at compile time.
// String path — no compile-time check
result.error?.issues.filter(i => i.path[0] === 'naem') // ← typo silently passes
// @cleverbrush/schema — typed selector
result.getErrorsFor(u => u.naem) // ← TypeScript error ✓Type-safe extension system
Add your own methods to schema builders — fully typed, autocomplete-ready. The built-in .email(), .url(), .uuid() methods use the same public API.
const slugExt = defineExtension({
string: {
slug(this: StringSchemaBuilder) {
return this.matches(/^[a-z0-9-]+$/);
}
}
});
const { string: s } = withExtensions(slugExt);
const PostSlug = s().slug().minLength(3);
// ^ slug() is typed, autocomplete worksRuntime introspection & PropertyDescriptors
Every schema exposes a typed .introspect() descriptor tree. This powers the mapper, react-form, JSON Schema output — and your own tools. Navigate fields with typed lambdas, not strings.
const d = UserSchema.introspect();
d.properties.name.isRequired // boolean ✓
d.properties.age.validators // TypedValidator[] ✓
// PropertyDescriptors — typed field navigation
const desc = UserSchema.properties;
desc(u => u.address.city).getValue(data) // typed, refactor-safe@cleverbrush/schema
The foundation everything else builds on
Validate untrusted input at API boundaries, form submissions, or config files — with detailed error messages.
The TypeScript type is derived automatically from the schema. No duplicate interface declarations.
Every builder call returns a new instance. Schemas are safe to share across modules without side effects.
~4.1 KB gzipped (minimalist build) or ~17.7 KB for the full build. Runs in Node, Deno, Bun, and modern browsers.
Implements the Standard Schema spec, so it works alongside any compatible library out of the box — including Zod.
After validation, look up errors for a specific field using an arrow function — result.getErrorsFor(u => u.email)— instead of a plain string like errors['email']. TypeScript verifies the property exists at compile time, so a typo like u => u.emal is a build error, not a runtime surprise.
import { object, string, number, type InferType } from '@cleverbrush/schema';
// ── 1. Define once, get the type for free ───────────────────────────
const UserSchema = object({
name: string().minLength(2, 'Name must be at least 2 characters'),
email: string().minLength(5, 'Please enter a valid email'),
age: number().min(0).max(150)
});
type User = InferType<typeof UserSchema>;
// → { name: string; email: string; age: number }
// ── 2. Validate and read field-level errors with typed selectors ──────
const result = UserSchema.validate(rawInput);
if (!result.valid) {
// getErrorsFor is a method on the result — no magic strings
const nameErrors = result.getErrorsFor(u => u.name);
console.log(nameErrors.errors); // ['Name must be at least 2 characters']
}What the schema enables
@cleverbrush/schema's runtime introspection powers a family of companion libraries. Define your data shape once and get type-safe mapping, JSON Schema interop, and headless React forms — all from the same schema object.
Familiar API — migrate from Zod field by field
Most Zod primitives are drop-in replacements. The fluent builder style, optional(), default(), brand(), and readonly() all work identically. You can adopt @cleverbrush/schema incrementally — even wrapping existing Zod schemas with extern() to compose them into new objects.
// ── Zod ───────────────────────────────────────────────────────────
import { z } from 'zod';
const UserZ = z.object({
name: z.string().min(2),
email: z.string().min(5),
age: z.number().min(0).max(150),
});
type UserZ = z.infer<typeof UserZ>; // requires z.infer<>
// ── @cleverbrush/schema ──────────────────────────────────────────────
import { object, string, number, type InferType } from '@cleverbrush/schema';
const UserSchema = object({
name: string().minLength(2),
email: string().minLength(5),
age: number().min(0).max(150),
});
type User = InferType<typeof UserSchema>; // { name: string; email: string; age: number } — identical to z.infer<typeof UserZ>
// Validate — result.valid narrows the type automatically
const result = UserSchema.validate(rawInput);
if (result.valid) {
processUser(result.object); // typed as User ✓
}▶ Try this example in the PlaygroundImmutable & composable — share schemas safely
Every builder method returns a new instance. Schemas can be exported, extended, or narrowed anywhere in your codebase without risk of accidental mutation.
import { object, string, number, union, boolean, array, type InferType } from '@cleverbrush/schema';
// Reusable building blocks
const EmailField = string().minLength(5).maxLength(254);
const NameField = string().minLength(2).maxLength(50);
// Extend a base schema for two contexts — neither mutates the other
const CreateUserSchema = object({ name: NameField, email: EmailField });
const UpdateUserSchema = CreateUserSchema
.addProps({ role: string().optional() });
// Discriminated unions with full type narrowing
const MediaSchema = union(object({ type: string().equals('image'), url: string(), width: number(), height: number() }))
.or(object({ type: string().equals('video'), url: string(), duration: number() }))
.or(object({ type: string().equals('text'), body: string() }));
type Media = InferType<typeof MediaSchema>;
// { type: 'image'; url: string; width: number; height: number }
// | { type: 'video'; url: string; duration: number }
// | { type: 'text'; body: string }▶ Try this example in the PlaygroundAdvanced: adopt incrementally with extern()
Already invested in Zod (or any Standard Schema v1 compatible library)? Wrap existing schemas with extern() and use them as properties inside @cleverbrush/schema objects. Types are inferred across the boundary and getErrorsFor selectors work through it too.
import { z } from 'zod';
import { object, date, number, extern, type InferType } from '@cleverbrush/schema';
// Your existing Zod schema — untouched
const zodAddress = z.object({ street: z.string(), city: z.string() });
// Compose it into a @cleverbrush/schema object
const OrderSchema = object({
id: number(),
createdAt: date(),
address: extern(zodAddress), // ← any Standard Schema v1 compatible library
});
type Order = InferType<typeof OrderSchema>;
// { id: number; createdAt: Date; address: { street: string; city: string } }▶ Try extern() in the PlaygroundThoroughly Tested
Every package ships with a comprehensive unit test suite run with Vitest across the full monorepo. Coverage is measured and published on every release.
Performance
Numbers generated automatically from our benchmark suite (Vitest bench, Node >= 22). Higher is better. We show all tests— including the ones where we don't come first.
Valid Input
String (valid)
Flat object (valid)
Nested object (valid)
Complex object (valid)
Array of 100 (valid)
Union first (valid)
Union last (valid)
Zod wins this one.We show all results — including the ones we don't top.
Invalid Input
String (invalid)
Flat object (invalid)
Nested object (invalid)
Complex object (invalid)
Array of 100 (invalid)
Union no-match (invalid)
Benchmarks compare @cleverbrush/schema, Zod, Yup, and Joi. Each test runs for 500 ms per library. Full source in libs/benchmarks/.