Migrating from Zod

A side-by-side API reference for every Zod feature and its @cleverbrush/schema equivalent. Most primitives are drop-in replacements.

Why switch from Zod?

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:

🎯Typed field-error selectors

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.

🔍Runtime schema introspection

.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.

🧩Type-safe extension system

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.

Quick Reference

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/schemaNotes
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() patternFull 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 extensionsImport from @cleverbrush/schema (default export has extensions applied)
.introspect()Unique to @cleverbrush — runtime schema inspection

Installation

# Before
npm install zod

# After
npm install @cleverbrush/schema

Update 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';

Primitives

// 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();

String Validators

// 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().

Number Validators

// 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)

Object Schemas

▶ Open in Playground
// 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 }

Arrays

// 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)

Unions & Discriminated Unions

// 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 }

Enums

// 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

Parse & Validate

// 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.

Type Inference

// 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 }

Custom Validators

// 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'
);

Transforms & Preprocessors

// 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 thing

Key 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.

Default Values

// 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.

Branded Types

// 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')); // ✓

Readonly

// 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); // true

The .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.

🔍 Unique Advantage: Schema Introspection

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.

🧩 Unique Advantage: Type-Safe Extension System

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.

Honest Gaps

@cleverbrush/schemadoes not yet cover every Zod feature. Here is what's missing:

Zod FeatureStatusWorkaround
z.tuple([...])✓ Supportedtuple([string(), number()]) — import tuple from @cleverbrush/schema
z.record(key, value)✓ Supportedrecord(string(), number()) — import record from @cleverbrush/schema
z.map() / z.set()Not implementedUse any() + custom validator
z.null()✓ Supportednul() — note the spelling (avoids the JS reserved word)
z.undefined() / z.void() / z.never()Not implementedUse .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 itUse object().intersect(other)
z.promise()Not implemented
.transform() (post-validation output type change)Partial.addPreprocessor() runs pre-validation; output type stays the same

What You Gain

Switching to @cleverbrush/schema unlocks capabilities that are architecturally impossible in Zod:

  • Runtime introspection .introspect() gives you the full schema descriptor tree at runtime. Build code generators, form renderers, API doc generators, and more — directly from your schema.
  • Type-safe extension methods — Add real builder methods with full TypeScript support. Not just .refine() callbacks — composable, discoverable, autocomplete-friendly methods.
  • Schema-driven ecosystem @cleverbrush/mapper and @cleverbrush/react-form are built on the same schema foundation. One schema definition drives validation, object mapping, and form rendering simultaneously.
  • Immutable by design — Every method returns a new instance. Safe to share base schemas across modules without fear of mutation bugs.
  • JSDoc preservation — Descriptions attached to schema properties flow through to IDE tooltips, because the schema is the single source of truth.