Extensions
▶ Open in PlaygroundThe extension system lets you add custom methods to any schema builder type without modifying the core library. Define an extension once, apply it with withExtensions(), and every builder produced by the returned factories includes your new methods — fully typed and chainable.
Defining an Extension
Use defineExtension() to declare which builder types your extension targets and what methods it adds. The system automatically attaches extension metadata — no boilerplate needed:
import { defineExtension, withExtensions, StringSchemaBuilder, NumberSchemaBuilder } from '@cleverbrush/schema';
// Email extension — adds .email() to string builders
const emailExt = defineExtension({
string: {
email(this: StringSchemaBuilder) {
return this.addValidator((val) => {
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val as string);
return { valid, errors: valid ? [] : [{ message: 'Invalid email address' }] };
});
}
}
});
// Port extension — adds .port() to number builders
const portExt = defineExtension({
number: {
port(this: NumberSchemaBuilder) {
return this.isInteger().min(1).max(65535);
}
}
});Using Extensions
Pass one or more extension descriptors to withExtensions() to get augmented factory functions. All original builder methods remain available:
const s = withExtensions(emailExt, portExt);
// .email() is now available on string builders
const EmailSchema = s.string().email().minLength(5);
// .port() is now available on number builders
const PortSchema = s.number().port();
// Use in object schemas
const ServerConfig = s.object({
adminEmail: s.string().email(),
port: s.number().port(),
name: s.string().minLength(1)
});Extension Metadata & Introspection
Extension methods automatically record metadata that can be inspected at runtime via .introspect().extensions. Zero-arg methods store true, single-arg methods store the argument, and multi-arg methods store the arguments array:
const schema = s.string().email();
const meta = schema.introspect();
console.log(meta.extensions.email); // true
// For methods with arguments:
const rangeExt = defineExtension({
number: {
range(this: NumberSchemaBuilder, min: number, max: number) {
return this.min(min).max(max);
}
}
});
const s2 = withExtensions(rangeExt);
const rangeSchema = s2.number().range(0, 100);
console.log(rangeSchema.introspect().extensions.range); // [0, 100]Custom Metadata
If you need structured metadata (e.g. an object with named fields), call this.withExtension(key, value) explicitly. The auto-infer logic detects the existing key and skips automatic attachment:
const currencyExt = defineExtension({
number: {
currency(this: NumberSchemaBuilder, opts?: { maxDecimals?: number }) {
const maxDec = opts?.maxDecimals ?? 2;
return this.withExtension('currency', { maxDecimals: maxDec })
.min(0)
.addValidator((val) => {
const decimals = (String(val).split('.')[1] ?? '').length;
const valid = decimals <= maxDec;
return { valid, errors: valid ? [] : [{ message: `Max ${maxDec} decimal places` }] };
});
}
}
});Build & Share Extensions
Extensions are plain objects — easy to publish as npm packages and share with the community. Unlike Zod's .refine() or Yup's .test(), extensions add named, discoverable methodsto the builder API with full TypeScript autocompletion. Unlike Joi's .extend(), extension methods are type-safe and composable without any casts.
We encourage the community to create and publish extensions for common use cases — email validation, currency formatting, URL slugs, phone numbers, and more. A well-typed extension is just a defineExtension() call away.