A side-by-side API reference for every Zod feature and its @cleverbrush/schema equivalent. Most primitives are drop-in replacements.
Zod is excellent — that's why we modelled our API after it. If Zod works for you, keep using it. @cleverbrush/schema offers three things Zod cannot:
Call result.getErrorsFor(u => u.fieldName) instead of filtering an errors array by string path. TypeScript catches typos at compile time — Zod's issues.filter(i => i.path[0] === 'fieldName') does not.
.introspect() returns the full descriptor tree at runtime — field names, validators, optionality, metadata. Zod schemas are opaque to code. This is what powers @cleverbrush/mapper and @cleverbrush/react-form.
Add real builder methods (with TypeScript type support) using defineExtension() + withExtensions(). Zod only exposes .refine() for custom logic — you cannot add new methods to the builder chain.
Also: roughly ~2× faster in array-validation benchmarks, tree-shakeable to ~4 KB per builder, Standard Schema v1 compatible, and you can wrap existing Zod schemas with extern() during incremental migration.
Most Zod concepts map 1-to-1. The table below covers the full API surface — scroll to any section for runnable code examples.
| Zod | @cleverbrush/schema | Notes |
|---|---|---|
z.string() | string() | Drop-in replacement |
z.number() | number() | Drop-in replacement |
z.boolean() | boolean() | Drop-in replacement |
z.date() | date() | Drop-in replacement |
z.any() | any() | Drop-in replacement |
z.object({{}) | object({{}) | Same shape syntax |
.extend({{}) | .addProps({{}) | Also accepts another ObjectSchemaBuilder |
.merge(other) | .intersect(other) | Combines two object schemas |
.pick({{}) | .pick([...]) | Accepts key array or another schema |
.omit({{}) | .omit([...]) | Accepts key array or another schema |
.partial() | .partial() | Also supports per-field .partial('field') |
z.array(s) | array(s) | Drop-in replacement |
.min(n) on array | .minLength(n) | Renamed for clarity |
.max(n) on array | .maxLength(n) | Renamed for clarity |
z.union([...]) | union(s1).or(s2).or(s3) | Chainable |
z.discriminatedUnion() | union().or() pattern | Full type inference; see Discriminated Unions section |
z.enum(['a', 'b']) | enumOf('a', 'b') | Also available as string().oneOf('a', 'b') |
.parse(v) | .parse(v) | Throws on invalid data |
.safeParse(v) | .validate(v) or .safeParse(v) | Both work; result shape differs from Zod (valid / object instead of success / data) |
.parseAsync(v) | .parseAsync(v) | Throws on invalid data; async |
.safeParseAsync(v) | .validateAsync(v) or .safeParseAsync(v) | Both work; same result shape note applies |
z.infer<typeof s> | InferType<typeof s> | Named import from the package |
.optional() | .optional() | Identical |
.refine(fn, msg) | .addValidator(fn, msgProvider) | See Custom Validators section |
.transform(fn) | .addPreprocessor(fn) | Runs before validation; see Transforms section |
.brand<'T'>() | .brand<'T'>() | Identical |
.readonly() | .readonly() | Identical — type-level only |
.email(), .url(), .uuid(), .ip() | Same — built-in extensions | Import from @cleverbrush/schema (default export has extensions applied) |
| — | .introspect() | Unique to @cleverbrush — runtime schema inspection |
# Before
npm install zod
# After
npm install @cleverbrush/schemaUpdate the import and replace z. call sites — most primitives are drop-in replacements.
// Before
import { z } from 'zod';
// After
import { string, number, boolean, object, array, union, InferType } from '@cleverbrush/schema';// Zod
const s = z.string();
const n = z.number();
const b = z.boolean();
const d = z.date();
const a = z.any();
// @cleverbrush/schema (identical call sites)
const s = string();
const n = number();
const b = boolean();
const d = date();
const a = any();// Zod
z.string().email()
z.string().url()
z.string().uuid()
z.string().ip()
z.string().min(2)
z.string().max(100)
z.string().regex(/^[a-z]+$/)
z.string().trim()
z.string().toLowerCase()
z.string().nonempty()
z.string().optional()
// @cleverbrush/schema
string().email()
string().url()
string().uuid()
string().ip()
string().minLength(2) // renamed: min → minLength
string().maxLength(100) // renamed: max → maxLength
string().matches(/^[a-z]+$/) // renamed: regex → matches
string().trim()
string().toLowerCase()
string().nonempty()
string().optional()The main naming difference is .min() / .max() become .minLength() / .maxLength() on strings (and arrays), and .regex() becomes .matches().
// Zod 3
z.number().int() // Zod 3 method-chain style
z.number().min(0)
z.number().max(100)
z.number().positive()
z.number().negative()
z.number().finite()
z.number().multipleOf(5)
// Zod 4 — integer moved to a top-level validator
z.int() // replaces z.number().int()
z.number().min(0) // .min() / .max() unchanged
// @cleverbrush/schema
number().isInteger() // renamed: int() → isInteger()
number().min(0)
number().max(100)
number().positive()
number().negative()
number().finite()
number().multipleOf(5)// Zod
const Base = z.object({ id: z.number(), name: z.string() });
const Extended = Base.extend({ email: z.string().email() });
const Merged = Base.merge(z.object({ role: z.string() }));
const Picked = Base.pick({ id: true });
const Omitted = Base.omit({ name: true });
const AllOptional = Base.partial();
const PartialName = Base.partial({ name: true });
// @cleverbrush/schema
const Base = object({ id: number(), name: string() });
const Extended = Base.addProps({ email: string().email() });
const Merged = Base.intersect(object({ role: string() }));
const Picked = Base.pick(['id']);
const Omitted = Base.omit(['name']);
const AllOptional = Base.partial();
const PartialName = Base.partial('name'); // single field — no object wrapper needed.addProps() also accepts another ObjectSchemaBuilder directly, which lets you compose schemas without listing property names:
const Timestamps = object({ createdAt: date(), updatedAt: date() });
const User = object({ id: number(), name: string() });
// Merge by passing the builder itself
const UserWithTimestamps = User.addProps(Timestamps);
// → { id: number, name: string, createdAt: Date, updatedAt: Date }// Zod
z.array(z.string())
z.array(z.string()).min(1)
z.array(z.string()).max(10)
z.array(z.string()).nonempty()
z.array(z.string()).length(5) // exact length — not available in @cleverbrush
// @cleverbrush/schema
array(string())
array(string()).minLength(1)
array(string()).maxLength(10)
array(string()).nonempty() // extension shorthand for minLength(1)
array(string()).unique() // extra: deduplication check (no Zod equivalent)// Zod — z.union takes an array
const StringOrNumber = z.union([z.string(), z.number()]);
// @cleverbrush/schema — union() is chainable with .or()
const StringOrNumber = union(string()).or(number());
// Discriminated union — Zod
const Shape = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('circle'), radius: z.number() }),
z.object({ kind: z.literal('rectangle'), width: z.number(), height: z.number() }),
]);
// Discriminated union — @cleverbrush/schema
// string().equals() acts as a literal; union infers the discriminant automatically
const Circle = object({ kind: string().equals('circle'), radius: number() });
const Rectangle = object({ kind: string().equals('rectangle'), width: number(), height: number() });
const Shape = union(Circle).or(Rectangle);
// TypeScript infers: { kind: 'circle'; radius: number } | { kind: 'rectangle'; width: number; height: number }// Zod
const Role = z.enum(['admin', 'user', 'guest']);
type Role = z.infer<typeof Role>; // 'admin' | 'user' | 'guest'
// @cleverbrush/schema — top-level factory
import { enumOf, InferType } from '@cleverbrush/schema';
const Role = enumOf('admin', 'user', 'guest');
type Role = InferType<typeof Role>; // 'admin' | 'user' | 'guest'
// or as an extension method on string()
const Role2 = string().oneOf('admin', 'user', 'guest');
// Also works on numbers
const Priority = number().oneOf(1, 2, 3);
type Priority = InferType<typeof Priority>; // 1 | 2 | 3// Zod
const user = UserSchema.parse(data); // throws ZodError on failure
const result = UserSchema.safeParse(data); // { success, data } | { success, error }
if (!result.success) console.log(result.error.issues);
// @cleverbrush/schema
const user = UserSchema.parse(data); // throws SchemaValidationError on failure
const result = UserSchema.validate(data); // { valid, object? }
if (!result.valid) {
console.log(result.getErrorsFor(u => u.fieldName).errors); // ['...']
}
// Zod-compat aliases also exist:
const result2 = UserSchema.safeParse(data); // alias for .validate()
const result3 = await UserSchema.safeParseAsync(data); // alias for .validateAsync()
// Async variants
const user2 = await UserSchema.parseAsync(data);
const result4 = await UserSchema.validateAsync(data);The result shape is different from Zod's { success, data }. In @cleverbrush/schema the result is { valid: boolean, object?: T, errors?: ValidationError[] }. For easier migration, .safeParse() and .safeParseAsync() exist as aliases for .validate() / .validateAsync() — note that the returned object shape still uses valid / object / errors, not Zod's success / data.
// Zod
import { z } from 'zod';
const UserSchema = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof UserSchema>;
// @cleverbrush/schema
import { object, string, number, InferType } from '@cleverbrush/schema';
const UserSchema = object({ name: string(), age: number() });
type User = InferType<typeof UserSchema>;
// → { name: string; age: number }// Zod — .refine(fn, message | options)
const EvenNumber = z.number().refine(n => n % 2 === 0, 'Must be even');
// With context-aware message:
const Username = z.string().refine(
s => !s.includes(' '),
val => ({ message: `"${val}" must not contain spaces` })
);
// @cleverbrush/schema — .addValidator(fn, messageProvider?)
// fn returns true (valid) or false (invalid)
const EvenNumber = number().addValidator(n => n % 2 === 0, 'Must be even');
// With value-aware message (message provider receives the value):
const Username = string().addValidator(
s => !s.includes(' '),
val => `"${val}" must not contain spaces`
);
// Async validators work the same way — just return a Promise<boolean>
const UniqueEmail = string().email().addValidator(
async email => !(await db.emailExists(email)),
'Email already taken'
);// Zod — .transform() runs after validation
const UpperEmail = z.string().email().transform(s => s.toLowerCase());
// @cleverbrush/schema — .addPreprocessor() runs before validation
// This is an important semantic difference: preprocess first, then validate
const LowerEmail = string().addPreprocessor(s => s.toLowerCase()).email();
// String extension shorthands cover the common cases:
const LowerEmail = string().toLowerCase().email(); // same thingKey difference: Zod's .transform() runs after validation, changing the output type. .addPreprocessor() runs before validation, normalising the raw input. For most real-world use cases (trim whitespace, normalise case, coerce strings to numbers) the preprocessor approach is cleaner and more predictable.
// Zod
const Name = z.string().default('Anonymous');
// @cleverbrush/schema (identical API)
const Name = string().default('Anonymous');
// Both support factory functions for mutable defaults:
// Zod: z.array(z.string()).default(() => []);
// Schema: array(string()).default(() => []);
// Both remove undefined from the inferred type:
// type Name = string (not string | undefined)The .default(value)API is identical to Zod. It accepts a static value or a factory function, and the default is validated against the schema's constraints. The inferred type automatically unwraps undefined.
// Zod
const Email = z.string().email().brand<'Email'>();
type Email = z.infer<typeof Email>;
// @cleverbrush/schema (identical)
const Email = string().email().brand<'Email'>();
type Email = InferType<typeof Email>;
// Both prevent accidental type mixing:
function sendEmail(to: Email) { /* ... */ }
sendEmail('user@example.com'); // ✗ TypeScript error — string is not Email
sendEmail(Email.parse('user@example.com')); // ✓// Zod
const User = z.object({ name: z.string() }).readonly();
type User = z.infer<typeof User>; // Readonly<{ name: string }>
// @cleverbrush/schema (identical)
const User = object({ name: string() }).readonly();
type User = InferType<typeof User>; // Readonly<{ name: string }>
// Works on arrays too:
const Tags = array(string()).readonly();
type Tags = InferType<typeof Tags>; // ReadonlyArray<string>
// Introspectable:
console.log(User.introspect().isReadonly); // trueThe .readonly()API is identical to Zod's. It is type-level only — it marks the inferred type as Readonly<T> (or ReadonlyArray<T> for arrays) but does not freeze the validated value at runtime. The isReadonly flag is exposed via .introspect() for tooling.
Zod schemas are opaque — you can validate data but cannot inspect the schema's structure at runtime. @cleverbrush/schema exposes a full descriptor tree via .introspect(), making schemas transparent and programmable.
import { object, string, number } from '@cleverbrush/schema';
const UserSchema = object({
name: string().minLength(1).maxLength(100),
email: string().email(),
age: number().min(0).optional()
});
// Inspect the schema structure at runtime
const descriptor = UserSchema.introspect();
console.log(descriptor.properties.name.isRequired); // true
console.log(descriptor.properties.age.isRequired); // false
console.log(descriptor.properties.email.type); // 'string'
// This powers @cleverbrush/mapper and @cleverbrush/react-form:
// the same schema drives type-safe object mapping AND auto-generated forms.
// Zod has no equivalent — introspection is architecturally impossible.This is the foundation for the @cleverbrush ecosystem. @cleverbrush/mapper uses PropertyDescriptors to provide type-safe property selectors for object transformation. @cleverbrush/react-form uses them to auto-generate and auto-validate form fields. Neither would be possible with Zod.
Zod's only extensibility mechanism is .refine() — you can add a validator, but you cannot add new methods to the builder with proper TypeScript types. @cleverbrush/schema has a first-class extension system: add methods that appear on the builder itself, fully typed, composable with everything else.
import { defineExtension, withExtensions, StringSchemaBuilder } from '@cleverbrush/schema';
// Define a reusable extension — each key is a builder type
const slugExtension = defineExtension({
string: {
slug(this: StringSchemaBuilder) {
return this.matches(/^[a-z0-9-]+$/).addValidator(
s => !s.startsWith('-') && !s.endsWith('-'),
'Slug must not start or end with a hyphen'
);
}
}
});
// Apply with withExtensions() — returns augmented factory functions
const { string: s } = withExtensions(slugExtension);
// Now .slug() is a real method — autocomplete works, no type casts
const PostSlug = s().slug().minLength(3).maxLength(60);The built-in .email(), .url(), .uuid(), .ip(), .positive(), .nonempty(), and .unique() methods are all implemented this way — the extension system is used in production, not just a theoretical feature.
@cleverbrush/schemadoes not yet cover every Zod feature. Here is what's missing:
| Zod Feature | Status | Workaround |
|---|---|---|
z.tuple([...]) | ✓ Supported | tuple([string(), number()]) — import tuple from @cleverbrush/schema |
z.record(key, value) | ✓ Supported | record(string(), number()) — import record from @cleverbrush/schema |
z.map() / z.set() | Not implemented | Use any() + custom validator |
z.null() | ✓ Supported | nul() — note the spelling (avoids the JS reserved word) |
z.undefined() / z.void() / z.never() | Not implemented | Use .optional() where nullability is needed |
.default(value) | ✓ Supported | .default(value) or .default(() => value) |
.catch(value) | ✓ Supported | .catch(value) or .catch(() => value) — returns fallback when validation fails instead of throwing |
z.intersection(a, b) | Object-level: .intersect() covers it | Use object().intersect(other) |
z.promise() | Not implemented | — |
.transform() (post-validation output type change) | Partial | .addPreprocessor() runs pre-validation; output type stays the same |
Switching to @cleverbrush/schema unlocks capabilities that are architecturally impossible in Zod:
.introspect() gives you the full schema descriptor tree at runtime. Build code generators, form renderers, API doc generators, and more — directly from your schema..refine() callbacks — composable, discoverable, autocomplete-friendly methods.