Schema Modifiers

Modifiers augment a schema with metadata or behavior without changing its core type. Every modifier returns a new immutable builder instance.

Default Values

▶ Open in Playground

Every schema builder supports .default(value). When the input is undefined, the default value is used instead — and the result is still validated against the schema's constraints.

import { string, number, array, date, object, type InferType } from '@cleverbrush/schema';

// Static default
const Name = string().default('Anonymous');
Name.validate(undefined); // { valid: true, object: 'Anonymous' }
Name.validate('Alice');   // { valid: true, object: 'Alice' }

// Factory function — useful for mutable defaults like arrays or dates
const Tags = array(string()).default(() => []);

// Works with .optional() — .default() removes undefined from the inferred type
const Port = number().optional().default(3000);
type Port = InferType<typeof Port>; // number

// Use factories for mutable values to avoid shared references
const Config = object({
  host: string().default('localhost'),
  port: number().default(8080),
  tags: array(string()).default(() => []),
  createdAt: date().default(() => new Date())
});

Default values are exposed via .introspect():

const schema = string().default('hello');
const info = schema.introspect();
console.log(info.hasDefault);    // true
console.log(info.defaultValue);  // 'hello'

Catch / Fallback

▶ Open in Playground

Every schema builder supports .catch(value). When validation fails for any reason — wrong type, constraint violation, missing required value — the fallback is returned as a successful result instead of errors.

Unlike .default(), which only fires when the input is undefined, .catch() fires on any validation failure. When .catch() is set, .validate() will never return an invalid result.

import { string, number, array } from '@cleverbrush/schema';

// Static fallback
const Name = string().catch('unknown');
Name.validate(42);        // { valid: true, object: 'unknown' }
Name.validate(null);      // { valid: true, object: 'unknown' }
Name.validate('Alice');   // { valid: true, object: 'Alice' }

// Constraint violation also triggers catch
const Age = number().min(0).catch(-1);
Age.validate(-5);         // { valid: true, object: -1 }

// Factory for mutable fallback values
const Tags = array(string()).catch(() => []);
const r1 = Tags.validate(null);  // { valid: true, object: [] }
const r2 = Tags.validate(null);  // { valid: true, object: [] }
// r1.object !== r2.object  — a fresh [] each time

// Introspectable
const schema = string().catch('unknown');
console.log(schema.introspect().hasCatch);    // true
console.log(schema.introspect().catchValue);  // 'unknown'

Optional and nullable fallbacks

Fallback values and factories respect the resolved output type: optional schemas allow undefined, and nullable schemas allow null.

const text = string().optional().catch(undefined);
const nullableText = string().nullable().catch(() => null);

text.parse(42); // undefined
nullableText.parse(42); // null
array(text).parse(['ok', 42]); // ['ok', undefined] — no entries dropped

Fallbacks are opt-in. A property fallback does not make a malformed required root object valid, and a fallback factory runs only when validation fails.

Optional schemas accept null at runtime even when their inferred type omits it. A fallback does not replace a value that passed validation, so normalize null explicitly when your application requires undefined:

const normalizedText = string().optional()
    .addPreprocessor(value => value == null ? undefined : value)
    .catch(undefined);

normalizedText.parse(null); // undefined

Preprocessors may return optional/nullable values, including asynchronously. Their callback parameter types do not guarantee that unknown input already has that type; guard untrusted values before using type-specific methods. Use parseAsync / validateAsync for async callbacks. Existing InferType and hasType behavior is unchanged; static overrides do not convert or validate runtime values.

Readonly

▶ Open in Playground

.readonly() is a type-level-only modifier — it marks the inferred TypeScript type as immutable without altering validation or freezing the value at runtime.

API reference table
BuilderEffect on InferType<T>
object(…).readonly()Readonly<{ … }>
array(…).readonly()ReadonlyArray<T>
date().readonly()Readonly<Date>
PrimitivesIdentity — already immutable
import { object, array, string, number, type InferType } from '@cleverbrush/schema';

const UserSchema = object({ name: string(), age: number() }).readonly();
type User = InferType<typeof UserSchema>;
// Readonly<{ name: string; age: number }>

const TagsSchema = array(string()).readonly();
type Tags = InferType<typeof TagsSchema>;
// ReadonlyArray<string>

console.log(UserSchema.introspect().isReadonly); // true

Note: .readonly() is shallow. For deeply nested immutability, apply it at each level.

Describe

▶ Open in Playground

.describe(text) attaches a human-readable description as metadata only — no effect on validation. The description is accessible via .introspect().description and is automatically emitted by toJsonSchema().

import { object, string, number } from '@cleverbrush/schema';
import { toJsonSchema } from '@cleverbrush/schema-json';

const ProductSchema = object({
    id:    string().uuid().describe('Unique product identifier'),
    name:  string().nonempty().describe('Display name shown to customers'),
    price: number().positive().describe('Price in USD')
}).describe('A product in the catalogue');

// Read at runtime
console.log(ProductSchema.introspect().description);
// 'A product in the catalogue'

// toJsonSchema emits description fields automatically
const schema = toJsonSchema(ProductSchema, { $schema: false });
// { type: 'object', description: 'A product in the catalogue', properties: { … } }

schemaName

.schemaName(name) attaches a component name for OpenAPI tooling. When used with @cleverbrush/server-openapi, schemas with a name are automatically extracted into components/schemas and referenced via $ref.

import { object, string, number } from '@cleverbrush/schema';

const UserSchema = object({
    id:   number(),
    name: string().nonempty(),
}).schemaName('User');

console.log(UserSchema.introspect().schemaName); // 'User'

// In the generated OpenAPI spec:
// { "$ref": "#/components/schemas/User" }

Reuse the definition with ordinary modifiers

const History = object({
    current: UserSchema,
    previous: UserSchema.optional().nullable().describe('Previous user')
});
// One User component, with local annotations and nullability for previous.

No wrapper is needed. The original remains unchanged, and concrete builder methods, extensions, inference and nested property selectors are preserved. Modifier chains reference the original definition, not another alias.

  • Presence/nullability: optional, required, nullable, notNullable.
  • Annotations: describe, example, readonly.
  • Type-only changes: brand, hasType, clearHasType, optimize.

Shape and rule changes become unnamed

Property edits, partial/pick/omit, constraints, validators, preprocessors, defaults, fallbacks and their available clear methods discard inherited names. Extension changes detach conservatively too. Later annotations or optionality do not reconnect the derivative; existing nested named children still reuse their own definitions.

const PatchUser = UserSchema.partial(); // unnamed
const UserWithEmail = UserSchema.addProp('email', string()); // unnamed
const PublicUser = UserSchema.omit('id').schemaName('PublicUser');
const ShortName = string().schemaName('Name').maxLength(20); // unnamed

Apply schemaName after shape/rule edits when the result needs a stable component name. Code that previously relied on edits retaining an inherited name should name the final result explicitly.

Explicit naming always creates an independent definition, even on an alias. Independent definitions sharing a name still conflict, including identical shapes; use-site modifiers of the same definition do not.

Canonical-reference metadata affects exporters, not runtime validation. Defaults and fallbacks follow ordinary builder semantics; clearDefault() removes the default completely without revealing a hidden canonical default. JSON Schema, OpenAPI and AsyncAPI keep one canonical definition with local annotations and nullability.

Promise Schemas

Use promise() to validate that a value is a Promise (or any thenable). Pass an optional schema to annotate the resolved value type.

import { promise, string, number, object, InferType } from '@cleverbrush/schema';

// Untyped — accepts any Promise
const schema = promise();
type Result = InferType<typeof schema>; // Promise<any>

// Typed — constrains the resolved value
const userPromise = promise(
    object({ id: number(), name: string() })
);
type UserPromise = InferType<typeof userPromise>;
// Promise<{ id: number; name: string }>

// Set/replace resolved type incrementally
const refined = promise()
    .hasResolvedType(number())
    .optional()
    .default(Promise.resolve(0));