The cornerstone of type-safe TypeScript
@cleverbrush/schema is an immutable, composable schema library that infers your TypeScript types at compile time and validates your data at runtime — with zero dependencies. It lays the foundation for a rich ecosystem — much like Zod has shown is possible.
Install only what you need — each package is independent and tree-shakeable.
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 in three areas that Zod cannot match.
Access per-field errors through a typed lambda — no magic strings, no brittle path navigation. TypeScript catches a misspelled field name at compile time.
// Zod — 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 ✓Both Zod and @cleverbrush/schema let you inspect schema structure at runtime — but @cleverbrush/schema was built with introspection as a first-class concern. Every descriptor returned by .introspect() is fully typed, so you get rich autocomplete and compile-time checks instead of navigating loosely-typed internal properties.
// Zod — possible, but via loosely-typed internals
schema._zod.def.shape.name // type: any
// @cleverbrush/schema — fully typed descriptor tree
const d = UserSchema.introspect();
d.properties.name.isRequired // boolean ✓
d.properties.age.isRequired // boolean ✓
d.properties.email.validators // TypedValidator[] ✓Add your own methods to schema builders — fully typed, autocomplete-ready. The built-in .email(), .url(), .uuid() methods use the same public API.
// Zod — only .refine(), no new builder methods
// @cleverbrush/schema — real methods on the builder
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 worksThe 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.
~~5 KB gzipped (minimalist build) or ~~17 KBfor 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']
}@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.
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 PlaygroundEvery 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 Playgroundextern()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 PlaygroundEvery package ships with a comprehensive unit test suite run with Vitest across the full monorepo. Coverage is measured and published on every release.
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.
Benchmarks compare @cleverbrush/schema, Zod, Yup, and Joi. Each test runs for 500 ms per library. Full source in libs/benchmarks/.