Property Descriptors for Schema Validation: Type-Safe Error Handling in TypeScript
Developers often grapple with validating complex nested objects in JavaScript projects. Traditional approaches using string paths to extract errors lead to runtime bugs during refactoring. The solution? Property Descriptors: a structured tree of property descriptors that provides type-safe selectors with full IDE autocomplete.
The core idea is that each schema builds a tree of nodes, where every descriptor knows its property path, linked schema, parent, and access methods. This lets you compile selectors via lambdas instead of fragile strings.
import { object, string, number, InferType } from '@cleverbrush/schema';
const OrderSchema = object({
id: string(),
customer: object({
name: string().minLength(2),
email: string().email(),
address: object({
city: string().required('City is required'),
zip: number()
})
}),
total: number().min(0)
});
const result = OrderSchema.validate({
id: '123',
customer: {
name: 'A',
email: 'not-an-email',
address: {
zip: -1
}
},
total: -5
});
const nameErrors = result.getErrorsFor(t => t.customer.name);
const cityErrors = result.getErrorsFor(t => t.customer.address.city);
Rename a field, and TypeScript catches it at compile time. Your IDE provides full autocomplete.
Immutable Fluent API and Builders
All schema method calls return new instances, preventing mutations. This makes composition and reuse a breeze.
The library offers 14 builder types:
string()— minLength, maxLength, matches, email, url, uuidnumber()— min, max, positive, negative, finite, multipleOfboolean()— basic validationdate()— minDate, maxDateobject()— named propertiesarray()— typed elements, nonemptyunion()— discriminated unionstuple()— fixed lengthrecord()— dynamic keysfunc()— function typingany()— with hasTypenul()— nulllazy()— recursive schemasextern()— Standard Schema v1 wrapper
Common methods on the base SchemaBuilder:
.optional()— accepts undefined.required('msg')— required field.nullable()— accepts null.default(value)— default value.catch(value)— fallback on error.readonly()— Readonly<T>.brand<'T'>()— branded types.describe('text')— JSDoc preservation.addValidator(fn)— custom logic.addPreprocessor(fn)— preprocessing.introspect()— metadata
Runtime Schema Introspection
The .introspect() method returns a full schema description: type, constraints, flags, descriptions, extensions. All available at runtime for tools and integrations.
const Name = string().minLength(2).maxLength(100).describe('User name').optional();
const info = Name.introspect();
// info.type === 'string'
// info.minLength === 2
// info.description === 'User name'
Introspection is fully expanded for each builder. Perfect for JSON Schema conversion, form generation, or custom renderers.
Typed Extension System
Extensions are composable, introspectable add-ons to the core. Example for HEX colors and ports:
const hexColorExt = defineExtension({
string: {
hexColor(this: StringSchemaBuilder) {
return this.addValidator((v) => {
const valid = /^#[0-9a-f]{6}$/i.test(v as string);
return {
valid,
errors: valid ? [] : [{ message: 'Must be a valid HEX color' }]
};
});
}
}
});
const s = withExtensions(hexColorExt, portExt);
const colorSchema = s.string().hexColor();
Extensions appear in .introspect().extensions. Built-ins include email, url, uuid, positive, nonempty.
Standard Schema v1 Compatibility
Schemas implement Standard Schema v1 for seamless integration with tRPC, TanStack Form, React Hook Form, Hono. extern() wraps Zod/Valibot into builders.
const UserSchema = object({
name: string().minLength(2),
address: extern(zodAddress)
});
Property Descriptors deliver type-safe error access even in mixed schemas.
Key Takeaways
- Type-safe selectors via Property Descriptors eliminate string paths and runtime bugs.
- Immutable fluent API simplifies composition without side effects.
- Runtime introspection extracts metadata for any tool.
- Extensions — typed, composable core add-ons.
- Standard Schema v1 ensures ecosystem compatibility.
— Editorial Team
No comments yet.