Skip to main content

Schema Composition

Decompose and reuse schemas across your application.

Why Composition?​

  • Avoid duplication β€” Write schemas once, use everywhere
  • Consistency β€” Same validation rules across forms
  • Maintainability β€” Update in one place
  • Testing β€” Test schemas in isolation

Factory Functions​

Always Use Factory Functions

Use functions that return schemas, not direct objects.

// βœ… Good β€” factory function (new object each time)
export const addressSchema = (): FormSchema<Address> => ({
street: { value: '' },
city: { value: '' },
});

// ❌ Bad β€” shared reference (forms share same object)
export const addressSchema = {
street: { value: '' },
city: { value: '' },
};

Reusable Field Schemas​

Create common field configurations:

schemas/common-fields.ts
import { FieldConfig } from '@reformer/core';

export const emailField = (): FieldConfig<string> => ({
value: '',
});

export const phoneField = (): FieldConfig<string> => ({
value: '',
});

export const dateField = (): FieldConfig<Date | null> => ({
value: null,
});

export const booleanField = (defaultValue = false): FieldConfig<boolean> => ({
value: defaultValue,
});

Usage:

const form = new GroupNode({
form: {
email: emailField(),
phone: phoneField(),
birthDate: dateField(),
newsletter: booleanField(true),
},
});

Reusable Group Schemas​

Create schemas for common data structures:

schemas/address-schema.ts
import { FormSchema } from '@reformer/core';

export interface Address {
street: string;
city: string;
state: string;
zipCode: string;
}

export const addressSchema = (): FormSchema<Address> => ({
street: { value: '' },
city: { value: '' },
state: { value: '' },
zipCode: { value: '' },
});
schemas/person-schema.ts
import { FormSchema } from '@reformer/core';

export interface Person {
firstName: string;
lastName: string;
email: string;
}

export const personSchema = (): FormSchema<Person> => ({
firstName: { value: '' },
lastName: { value: '' },
email: { value: '' },
});

Composing schemas:

interface UserForm {
person: Person;
billingAddress: Address;
shippingAddress: Address;
}

const form = new GroupNode<UserForm>({
form: {
person: personSchema(),
billingAddress: addressSchema(),
shippingAddress: addressSchema(),
},
});

Reusable Validation Sets​

Extract validation logic into functions:

validators/address-validators.ts
import { FieldPath } from '@reformer/core';
import { required, pattern } from '@reformer/core/validators';
import { Address } from '../schemas/address-schema';

export function validateAddress(path: FieldPath<Address>) {
required(path.street);
required(path.city);
required(path.state);
required(path.zipCode);
pattern(path.zipCode, /^\d{5}(-\d{4})?$/, 'Invalid ZIP code');
}
validators/person-validators.ts
import { FieldPath } from '@reformer/core';
import { required, email, minLength } from '@reformer/core/validators';
import { Person } from '../schemas/person-schema';

export function validatePerson(path: FieldPath<Person>) {
required(path.firstName);
minLength(path.firstName, 2);
required(path.lastName);
required(path.email);
email(path.email);
}

Usage:

const form = new GroupNode<UserForm>({
form: {
person: personSchema(),
billingAddress: addressSchema(),
shippingAddress: addressSchema(),
},
validation: (path) => {
validatePerson(path.person);
validateAddress(path.billingAddress);
validateAddress(path.shippingAddress);
},
});

Reusable Behavior Sets​

Extract behavior logic into functions:

behaviors/address-behaviors.ts
import { FieldPath } from '@reformer/core';
import { transformValue } from '@reformer/core/behaviors';
import { Address } from '../schemas/address-schema';

export function addressBehaviors(path: FieldPath<Address>) {
// Auto-format ZIP code
transformValue(path.zipCode, (value) => {
const digits = value.replace(/\D/g, '');
if (digits.length === 9) {
return `${digits.slice(0, 5)}-${digits.slice(5)}`;
}
return value;
});
}

Usage:

const form = new GroupNode<UserForm>({
form: {
person: personSchema(),
billingAddress: addressSchema(),
shippingAddress: addressSchema(),
},
behavior: (path) => {
addressBehaviors(path.billingAddress);
addressBehaviors(path.shippingAddress);
},
});

Complete Module Pattern​

Bundle schema, validation, and behaviors together:

modules/
└── contact-info/
β”œβ”€β”€ schema.ts # Type + form schema
β”œβ”€β”€ validators.ts # Validation rules
β”œβ”€β”€ behaviors.ts # Reactive logic
└── index.ts # Public exports
modules/contact-info/schema.ts
import { FormSchema } from '@reformer/core';

export interface ContactInfo {
email: string;
phone: string;
preferredContact: 'email' | 'phone';
}

export const contactInfoSchema = (): FormSchema<ContactInfo> => ({
email: { value: '' },
phone: { value: '' },
preferredContact: { value: 'email' },
});
modules/contact-info/validators.ts
import { FieldPath } from '@reformer/core';
import { required, email, pattern } from '@reformer/core/validators';
import { ContactInfo } from './schema';

export function validateContactInfo(path: FieldPath<ContactInfo>) {
required(path.email);
email(path.email);
required(path.phone);
pattern(path.phone, /^\d{10}$/, 'Must be 10 digits');
}
modules/contact-info/behaviors.ts
import { FieldPath } from '@reformer/core';
import { transformValue } from '@reformer/core/behaviors';
import { ContactInfo } from './schema';

export function contactInfoBehaviors(path: FieldPath<ContactInfo>) {
transformValue(path.phone, (value) => {
const digits = value.replace(/\D/g, '');
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return value;
});
}
modules/contact-info/index.ts
export { contactInfoSchema, type ContactInfo } from './schema';
export { validateContactInfo } from './validators';
export { contactInfoBehaviors } from './behaviors';

Usage:

import {
contactInfoSchema,
validateContactInfo,
contactInfoBehaviors,
type ContactInfo,
} from './modules/contact-info';

interface MyForm {
name: string;
contactInfo: ContactInfo;
}

const form = new GroupNode<MyForm>({
form: {
name: { value: '' },
contactInfo: contactInfoSchema(),
},
validation: (path) => {
required(path.name);
validateContactInfo(path.contactInfo);
},
behavior: (path) => {
contactInfoBehaviors(path.contactInfo);
},
});

Configurable Schemas​

Create schema factories with options:

schemas/configurable-person.ts
interface PersonSchemaOptions {
includeMiddleName?: boolean;
includePhone?: boolean;
}

export function createPersonSchema(options: PersonSchemaOptions = {}): FormSchema<Person> {
const schema: FormSchema<Person> = {
firstName: { value: '' },
lastName: { value: '' },
email: { value: '' },
};

if (options.includeMiddleName) {
schema.middleName = { value: '' };
}

if (options.includePhone) {
schema.phone = { value: '' };
}

return schema;
}

Usage:

// Basic person
const simple = createPersonSchema();

// Person with all fields
const detailed = createPersonSchema({
includeMiddleName: true,
includePhone: true,
});
src/
β”œβ”€β”€ forms/ # Form instances
β”‚ β”œβ”€β”€ user-form.ts
β”‚ └── order-form.ts
β”‚
β”œβ”€β”€ schemas/ # Reusable schemas
β”‚ β”œβ”€β”€ common-fields.ts
β”‚ β”œβ”€β”€ address-schema.ts
β”‚ └── person-schema.ts
β”‚
β”œβ”€β”€ validators/ # Reusable validators
β”‚ β”œβ”€β”€ address-validators.ts
β”‚ └── person-validators.ts
β”‚
β”œβ”€β”€ behaviors/ # Reusable behaviors
β”‚ β”œβ”€β”€ address-behaviors.ts
β”‚ └── format-behaviors.ts
β”‚
└── modules/ # Complete modules
β”œβ”€β”€ contact-info/
β”‚ β”œβ”€β”€ schema.ts
β”‚ β”œβ”€β”€ validators.ts
β”‚ β”œβ”€β”€ behaviors.ts
β”‚ └── index.ts
└── payment-info/
└── ...

Best Practices​

PracticeWhy
Use factory functionsAvoid shared references
Export types with schemasBetter type inference
Bundle related schemasSingle import for module
Use descriptive namesvalidatePerson not validate1
Test schemas separatelyEasier debugging

Next Steps​