PropertyDescriptors

Every object schema in @cleverbrush/schema carries a PropertyDescriptor tree — a typed, runtime representation of every field in the schema, including nested objects. This makes it possible to reference a specific property using an ordinary arrow function like t => t.address.city instead of a fragile string like "address.city".

Because the selector is a real TypeScript expression, the compiler checks it. Rename city to town and every selector that references it becomes a compile error. No silent runtime breakage. No stale string paths hiding in the codebase.

This mechanism is what enables the rest of the ecosystem. No other popular TypeScript schema validation library exposes this — Zod, Yup, and Joi all rely on string-based paths for anything beyond basic validation.

Example: type-safe form fields with @cleverbrush/react-form

Every popular React form library today — React Hook Form, Formik, React Final Form — binds fields by string name. Rename a property and the compiler says nothing. The form just breaks silently at runtime.

Because @cleverbrush/react-form uses PropertyDescriptor selectors under the hood, <Field> accepts a typed arrow function instead:

// React Hook Form — string paths, no compile-time safety
const { register } = useForm<User>();
<input {...register("emial")} />            // ← typo silently fails at runtime
<input {...register("address.citey")} />    // ← wrong path: no error until runtime

// @cleverbrush/react-form — PropertyDescriptor selectors
<Field forProperty={t => t.email} form={form} />           // ✓ checked at compile time
<Field forProperty={t => t.address.city} form={form} />    // ✓ rename "city" → compiler error immediately
<Field forProperty={t => t.emial} form={form} />           // ✗ compile error: "emial" does not exist

The schema is the single source of truth — validation rules, TypeScript types, and form field configuration all come from it. See the react-form page for a full live demo.

Example: compile-time-complete mapping with @cleverbrush/mapper

Object mapping between schemas uses the same selector pattern. The .for() method accepts a typed arrow function pointing at a target property. If any target property is left unmapped, the TypeScript compiler reports an error before you run a single line of code:

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

const ApiUser = object({
  first_name: string(),
  last_name:  string(),
  birth_year: number()
});

const DomainUser = object({
  fullName: string(),
  age:      number()
});

const registry = mapper()
  .configure(ApiUser, DomainUser, m =>
    m
      .for(t => t.fullName)
        .compute(src => src.first_name + ' ' + src.last_name)
      .for(t => t.age)
        .compute(src => new Date().getFullYear() - src.birth_year)
  );

const mapFn = registry.getMapper(ApiUser, DomainUser);
const user  = await mapFn({ first_name: 'Jane', last_name: 'Doe', birth_year: 1995 });
// { fullName: 'Jane Doe', age: 30 }

// Forget to map a property? Compile error — not a runtime surprise.
// The type system tracks which properties are still unmapped.

See the mapper page for the full API, auto-mapping, and nested schema support.

Works with Zod schemas via extern()

Already using Zod? Wrap any Standard Schema v1 compatible schema with extern() and the property descriptor tree extends through it automatically. You get the same typed selectors across the Zod boundary — no string paths needed:

import { z } from 'zod';
import { object, number, extern, type InferType } from '@cleverbrush/schema';

// Existing Zod schema — left completely untouched
const ZodAddress = z.object({
  street: z.string(),
  city:   z.string(),
  zip:    z.string()
});

// Compose into a @cleverbrush/schema object with extern()
const OrderSchema = object({
  id:      number(),
  address: extern(ZodAddress),
});

type Order = InferType<typeof OrderSchema>;
// { id: number; address: { street: string; city: string; zip: string } }

// Validate and use typed selectors across the extern() boundary
const result = OrderSchema.validate({ id: 1, address: { street: '5th Ave', city: 'NYC', zip: '10001' } });
if (!result.valid) {
  // getErrorsFor works through the extern boundary — no string path needed
  const zipErrors = result.getErrorsFor(t => t.address.zip);
  console.log(zipErrors.errors); // ['...']
}

This means you can adopt @cleverbrush/schema incrementally — keep existing Zod schemas intact, wrap them with extern(), and immediately gain typed selectors for mapping and form binding.