Plugin Registry API

The Plugin Registry provides a powerful system for managing plugins and creating reusable field validation rules that can be shared across multiple validators and tested independently.

When to Use Plugin Registry

Use Plugin Registry for:

  • Single field validation - Testing and validating individual fields in isolation
  • Reusable field rules - Creating field validation logic that can be shared across multiple forms
  • Unit testing - Testing field validation logic independently before integration
  • Team-wide field standards - Defining consistent validation for common fields (email, phone, etc.)

Use Builder directly for:

  • Objects and Arrays - When validating objects with multiple fields or arrays of items
  • Multi-field validation - When fields depend on each other (e.g., compareField)
  • Complete forms - When validating entire data structures
  • One-off validators - When field rules won't be reused

Key Point: Plugin Registry is primarily for single field validation scenarios where you need to test, reuse, or standardize individual field rules.

Important: Builder also provides a pick() API to extract and validate individual fields. If you already have a Builder, use it instead of Plugin Registry whenever possible.

// If you have a Builder, use pick() instead of Plugin Registry
const validator = Builder()...build();
const emailValidator = validator.pick('email');
const result = emailValidator.validate('test@example.com');

Overview

📌 Recommendation: When possible, use Builder directly instead of Plugin Registry. Builder's pick() API can extract individual field validators, making Plugin Registry unnecessary in most cases.

The Plugin Registry serves three main purposes:

  1. Single Field Validation - Create and test individual field validation rules in isolation (when Builder is not available)
  2. Field Rule Reusability - Pre-define validation rules that can be shared across multiple forms and validators
  3. Team Standardization - Ensure consistent validation logic for common fields across teams and projects

Key Concepts

Core Concepts

  • Registry: Container for plugins that can create field rules and builders
  • Field Rule: Pre-defined, reusable validation logic for a single field
  • Plugin Accumulation: Registry tracks all registered plugins with type safety
  • Standalone Validation: Field rules can validate data independently
  • Builder Integration: Seamless use with Builder API via useField()

Creating a Registry

Start by creating a registry and registering the plugins you need:

import { createPluginRegistry } from '@maroonedog/luq/core/registry';
import {
  requiredPlugin,
  optionalPlugin,
  stringMinPlugin,
  stringMaxPlugin,
  stringEmailPlugin,
  stringPatternPlugin,
  numberMinPlugin,
  numberMaxPlugin,
  transformPlugin
} from '@maroonedog/luq/plugin';

// Create a registry and register plugins
const registry = createPluginRegistry()
  .use(requiredPlugin)
  .use(optionalPlugin)
  .use(stringMinPlugin)
  .use(stringMaxPlugin)
  .use(stringEmailPlugin)
  .use(stringPatternPlugin)
  .use(numberMinPlugin)
  .use(numberMaxPlugin)
  .use(transformPlugin);

// Registry now has all plugin methods available for field rules

Registering Plugins

Plugins can be registered using the use() method, which accumulates type information:

// Method 1: Chain multiple use() calls
const registry1 = createPluginRegistry()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(stringEmailPlugin);

// Method 2: Custom plugins can also be registered
const customPlugin = plugin({
  name: 'customValidator',
  methodName: 'customRule',
  allowedTypes: ['string'],
  category: 'standard',
  create: (options) => ({
    check: (value, context) => {
      // Custom validation logic
      return myCustomCheck(value);
    }
  })
});

const registry2 = createPluginRegistry()
  .use(requiredPlugin)
  .use(customPlugin); // Custom plugin registered

// Type-safe: registry knows about all registered plugins
// IDE will autocomplete available methods

Field Rules

Field Rules are the core feature of the Plugin Registry - they encapsulate validation logic for individual fields and can be reused across multiple validators.

createFieldRule() Method

The createFieldRule() method creates a reusable validation rule with full type safety:

Important: Field Names and Type Safety

  • Nested fields MUST use dot notation: 'profile.firstName', 'address.country.code'
  • Array elements use bracket notation: 'tags[*]', 'items[*].price'
  • Extract types from your interface: Use TypeOfPath<T, 'field.path'> for type safety
  • Use actual field types: createFieldRule<UserProfile['email']> not createFieldRule<string>
// Define your data structure
interface UserProfile {
  email: string;
  username: string;
  age: number;
  password: string;
  profile: {
    firstName: string;
    lastName: string;
    bio?: string;
  };
  address: {
    street: string;
    city: string;
    country: {
      code: string;
      name: string;
    };
  };
  tags: string[];
}

// NEW: Type-safe registry with for<Type>()
const typedRegistry = registry.for<UserProfile>();

// Now createFieldRule has type-safe names and automatic type inference!
const emailRule = typedRegistry.createFieldRule(
  b => b.string.required().email(),
  { 
    name: 'email', // ✅ TypeScript knows this must be a valid field path
    description: 'Valid email address'
  }
);
// emailRule is automatically typed as FieldRule<string>

// Nested fields - name must use dot notation
const firstNameRule = typedRegistry.createFieldRule(
  b => b.string.required().min(2),
  {
    name: 'profile.firstName', // ✅ Type-safe, must be valid path
    description: 'First name (min 2 chars)'
  }
);
// Automatically inferred as FieldRule<string>

// Deeply nested field - TypeScript enforces correct path
const countryCodeRule = typedRegistry.createFieldRule(
  b => b.string.required().pattern(/^[A-Z]{2}$/),
  {
    name: 'address.country.code', // ✅ Type-checked path
    description: 'ISO country code'
  }
);

// Invalid path causes TypeScript error
// const invalidRule = typedRegistry.createFieldRule(
//   b => b.string.required(),
//   { name: 'invalid.path' } // ❌ TypeScript Error: not a valid path
// );

// Optional field - type is correctly inferred as string | undefined
const bioRule = typedRegistry.createFieldRule(
  b => b.string.optional().max(500),
  {
    name: 'profile.bio', // Type inferred as string | undefined
    description: 'User biography'
  }
);

// Explicit type override when needed
const ageFromStringRule = typedRegistry.createFieldRule<string>(
  b => b.string.required().pattern(/^\d+$/),
  {
    name: 'age', // Override: treating number field as string input
    description: 'Age from form input'
  }
);

// Array validation
const tagsRule = typedRegistry.createFieldRule(
  b => b.array.required().minLength(1),
  {
    name: 'tags', // Automatically typed as string[]
    description: 'User tags'
  }
);

Using Field Rules

Field rules can be used standalone or integrated with builders:

// Type-safe usage with the defined UserProfile interface
const userValidator = registry
  .toBuilder()
  .for<UserProfile>() // Same interface used in field rules
  .useField('email', emailRule)
  .useField('profile.firstName', firstNameRule)        // ✅ Dot notation
  .useField('profile.bio', bioRule)                   // ✅ Optional field
  .useField('address.country.code', countryCodeRule)  // ✅ Deeply nested
  .useField('tags', tagsRule)                        // ✅ Array field
  .useField('tags[*]', tagElementRule)               // ✅ Array elements
  .v('username', b => b.string.required())           // Can mix with inline
  .build();

// Standalone validation - field rules work independently
const emailResult = emailRule.validate('user@example.com');
if (emailResult.isValid()) {
  const validEmail: string = emailResult.unwrap(); // Type-safe result
}

// Type mismatch protection
// const wrongRule = registry.createFieldRule<number>(...) 
// userValidator.useField('email', wrongRule) // ❌ TypeScript error!

// Correct type extraction for complex fields
type AddressCountryCode = TypeOfPath<UserProfile, 'address.country.code'>; // string
type ProfileBio = TypeOfPath<UserProfile, 'profile.bio'>; // string | undefined

// Using with partial types
interface UserUpdateForm {
  email?: string;
  profile?: {
    firstName?: string;
    bio?: string;
  };
}

const updateValidator = registry
  .toBuilder()
  .for<UserUpdateForm>()
  .useField('email', emailRule)                      // Reuse same rules
  .useField('profile.firstName', firstNameRule)      // Even for partial types
  .build();

Testing Field Rules

One of the key benefits is the ability to unit test field rules in isolation:

// Unit testing individual field rules
describe('Email Rule', () => {
  it('should accept valid emails', () => {
    const result = emailRule.validate('test@example.com');
    expect(result.isValid()).toBe(true);
  });

  it('should reject invalid emails', () => {
    const result = emailRule.validate('not-an-email');
    expect(result.isValid()).toBe(false);
    expect(result.errors[0].code).toBe('invalid_email');
  });

  it('should handle empty values', () => {
    const result = emailRule.validate('');
    expect(result.isValid()).toBe(false);
    expect(result.errors[0].code).toBe('required');
  });
});

// Testing with different inputs
const testCases = [
  { input: 'valid@email.com', expected: true },
  { input: 'invalid.email', expected: false },
  { input: '', expected: false },
  { input: null, expected: false },
];

testCases.forEach(({ input, expected }) => {
  const result = emailRule.validate(input);
  console.log(`Input: ${input}, Valid: ${result.isValid()}, Expected: ${expected}`);
});

Builder Integration

toBuilder() Method

Convert a registry to a builder with all registered plugins:

// Convert registry to builder with all registered plugins
const builder = registry.toBuilder();

// The builder has all plugins from the registry
const validator = builder
  .for<FormData>()
  .v('field1', b => b.string.required().min(5))
  .v('field2', b => b.number.required().min(0).max(100))
  .build();

// Useful for creating multiple validators with same plugins
const validator1 = registry.toBuilder()
  .for<Form1>()
  .v('email', b => b.string.email())
  .build();

const validator2 = registry.toBuilder()
  .for<Form2>()
  .v('username', b => b.string.min(3))
  .build();

// All validators share the same plugin configuration

useField() with Rules

The useField() method on builders accepts field rules created by the registry. This provides type safety and reusability.

Important Note

Field rules created with createFieldRule() include both validation logic AND field options (defaults, metadata). When using useField(), these options are automatically applied.

Common Patterns

Type-Safe Pattern

Always extract types from your actual interfaces:

// ✅ CORRECT - Type-safe
interface User {
  email: string;
  profile: { name: string; age: number; };
}

const emailRule = registry.createFieldRule<User['email']>(...)
const nameRule = registry.createFieldRule<TypeOfPath<User, 'profile.name'>>(...)

// ❌ WRONG - Loses type connection
const emailRule = registry.createFieldRule<string>(...)  // Too generic!

Field names must match exact paths:

// ✅ CORRECT - Dot notation for nested
{ name: 'profile.name' }
{ name: 'address.country.code' }
{ name: 'items[*].price' }

// ❌ WRONG - Invalid path formats
{ name: 'profile_name' }      // Underscore instead of dot
{ name: 'profile/name' }      // Slash instead of dot
{ name: 'profile' }           // Missing nested field

Team-Wide Rules

Create a centralized module for team-wide validation rules with proper type safety:

// team-validation-rules.ts - Shared across the team
import { createPluginRegistry } from '@maroonedog/luq/core/registry';
import { 
  requiredPlugin, 
  stringEmailPlugin,
  stringMinPlugin,
  stringPatternPlugin,
  numberMinPlugin,
  transformPlugin
} from '@maroonedog/luq/plugin';

// Create team registry with all needed plugins
export const teamRegistry = createPluginRegistry()
  .use(requiredPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .use(stringPatternPlugin)
  .use(numberMinPlugin)
  .use(transformPlugin);

// Define team-wide validation rules
export const teamRules = {
  // Company email must use corporate domain
  corporateEmail: teamRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .email()
      .pattern(/@mycompany\.com$/),
    {
      name: 'corporateEmail',
      description: 'Corporate email address (@mycompany.com)',
      fieldOptions: {
        metadata: {
          errorMessage: 'Please use your corporate email address'
        }
      }
    }
  ),

  // Employee ID format
  employeeId: teamRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .pattern(/^EMP-\d{6}$/),
    {
      name: 'employeeId',
      description: 'Employee ID (EMP-XXXXXX format)'
    }
  ),

  // Phone number with normalization
  phoneNumber: teamRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .pattern(/^[\d\s\-\(\)\+]+$/)
      .transform(v => v.replace(/[\s\-\(\)]/g, '')),
    {
      name: 'phoneNumber',
      description: 'Phone number (will be normalized)'
    }
  ),

  // Department code
  departmentCode: teamRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .pattern(/^[A-Z]{3}\d{3}$/),
    {
      name: 'departmentCode',
      description: 'Department code (XXX000 format)'
    }
  ),

  // Salary range
  salary: teamRegistry.createFieldRule<number>(
    b => b.number
      .required()
      .min(30000)
      .max(500000),
    {
      name: 'salary',
      description: 'Annual salary (30k-500k range)'
    }
  )
};

// Usage in different parts of the application
import { teamRules, teamRegistry } from './team-validation-rules';

// HR form validator
const hrFormValidator = teamRegistry.toBuilder()
  .for<HRForm>()
  .useField('email', teamRules.corporateEmail)
  .useField('employeeId', teamRules.employeeId)
  .useField('department', teamRules.departmentCode)
  .useField('salary', teamRules.salary)
  .build();

// Employee profile validator
const profileValidator = teamRegistry.toBuilder()
  .for<EmployeeProfile>()
  .useField('workEmail', teamRules.corporateEmail)
  .useField('phone', teamRules.phoneNumber)
  .useField('empId', teamRules.employeeId)
  .build();

Rule Composition

Build complex rules by composing simpler ones:

// Composing complex rules from simpler ones
const baseRegistry = createPluginRegistry()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(stringMaxPlugin)
  .use(stringPatternPlugin)
  .use(transformPlugin);

// Base rules
const trimmedStringRule = baseRegistry.createFieldRule<string>(
  b => b.string.transform(v => v.trim()),
  { name: 'trimmedString' }
);

const nonEmptyStringRule = baseRegistry.createFieldRule<string>(
  b => b.string.required().min(1),
  { name: 'nonEmptyString' }
);

// Composed rules using base rules as building blocks
const nameRule = baseRegistry.createFieldRule<string>(
  b => b.string
    .required()
    .transform(v => v.trim())
    .min(2)
    .max(50)
    .pattern(/^[a-zA-Z\s]+$/),
  {
    name: 'personName',
    description: 'Person name (2-50 chars, letters only)'
  }
);

// Domain-specific rules
const productCodeRule = baseRegistry.createFieldRule<string>(
  b => b.string
    .required()
    .transform(v => v.toUpperCase().trim())
    .pattern(/^[A-Z]{3}-\d{4}$/),
  {
    name: 'productCode',
    description: 'Product code (XXX-0000 format)'
  }
);

// Rules with complex transformations
const priceRule = baseRegistry.createFieldRule<string>(
  b => b.string
    .required()
    .pattern(/^\d+(\.\d{1,2})?$/)
    .transform(v => parseFloat(v))
    .transform(v => Math.round(v * 100) / 100), // Ensure 2 decimal places
  {
    name: 'price',
    description: 'Price with up to 2 decimal places'
  }
);

Complete Examples

Here's a complete example showing a multi-step form with shared validation rules:

// Complete example: Multi-step form validation
import { createPluginRegistry } from '@maroonedog/luq/core/registry';
import { 
  requiredPlugin,
  optionalPlugin,
  stringMinPlugin,
  stringEmailPlugin,
  stringPatternPlugin,
  numberMinPlugin,
  numberMaxPlugin,
  compareFieldPlugin,
  transformPlugin,
  conditionalPlugin
} from '@maroonedog/luq/plugin';

// 1. Setup registry with all needed plugins
const formRegistry = createPluginRegistry()
  .use(requiredPlugin)
  .use(optionalPlugin)
  .use(stringMinPlugin)
  .use(stringEmailPlugin)
  .use(stringPatternPlugin)
  .use(numberMinPlugin)
  .use(numberMaxPlugin)
  .use(compareFieldPlugin)
  .use(transformPlugin)
  .use(conditionalPlugin);

// 2. Define reusable field rules
const formRules = {
  email: formRegistry.createFieldRule<string>(
    b => b.string.required().email(),
    { 
      name: 'email',
      fieldOptions: {
        metadata: { 
          placeholder: 'email@example.com',
          autocomplete: 'email' 
        }
      }
    }
  ),

  password: formRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/),
    {
      name: 'password',
      description: 'Password with uppercase, lowercase, number, and special char'
    }
  ),

  confirmPassword: formRegistry.createFieldRule<string>(
    b => b.string.required(),
    { name: 'confirmPassword' }
  ),

  age: formRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .pattern(/^\d+$/)
      .transform(v => parseInt(v, 10))
      .min(13)
      .max(120),
    { name: 'age' }
  ),

  phoneNumber: formRegistry.createFieldRule<string>(
    b => b.string
      .optional()
      .pattern(/^\+?[1-9]\d{1,14}$/)
      .transform(v => v?.replace(/\D/g, '')),
    { 
      name: 'phoneNumber',
      fieldOptions: {
        default: '',
        metadata: { format: 'E.164' }
      }
    }
  ),

  zipCode: formRegistry.createFieldRule<string>(
    b => b.string
      .required()
      .pattern(/^\d{5}(-\d{4})?$/),
    { name: 'zipCode' }
  )
};

// 3. Create validators for different form steps
// Step 1: Account Info
const step1Validator = formRegistry.toBuilder()
  .for<Step1Data>()
  .useField('email', formRules.email)
  .useField('password', formRules.password)
  .useField('confirmPassword', formRules.confirmPassword)
  .v('confirmPassword', b => b.string.compareField('password'))
  .build();

// Step 2: Personal Info
const step2Validator = formRegistry.toBuilder()
  .for<Step2Data>()
  .v('firstName', b => b.string.required().min(2))
  .v('lastName', b => b.string.required().min(2))
  .useField('age', formRules.age)
  .useField('phone', formRules.phoneNumber)
  .build();

// Step 3: Address Info
const step3Validator = formRegistry.toBuilder()
  .for<Step3Data>()
  .v('street', b => b.string.required())
  .v('city', b => b.string.required())
  .v('state', b => b.string.required().min(2).max(2))
  .useField('zipCode', formRules.zipCode)
  .build();

// 4. Form handler with validation
class MultiStepForm {
  private currentStep = 1;
  private formData: any = {};

  async validateStep1(data: Step1Data) {
    const result = step1Validator.parse(data);
    
    if (result.isValid()) {
      this.formData = { ...this.formData, ...result.unwrap() };
      this.currentStep = 2;
      return { success: true };
    }
    
    return { 
      success: false, 
      errors: result.errors 
    };
  }

  async validateStep2(data: Step2Data) {
    const result = step2Validator.parse(data);
    
    if (result.isValid()) {
      this.formData = { ...this.formData, ...result.unwrap() };
      this.currentStep = 3;
      return { success: true };
    }
    
    return { 
      success: false, 
      errors: result.errors 
    };
  }

  async validateStep3(data: Step3Data) {
    const result = step3Validator.parse(data);
    
    if (result.isValid()) {
      this.formData = { ...this.formData, ...result.unwrap() };
      return this.submitForm();
    }
    
    return { 
      success: false, 
      errors: result.errors 
    };
  }

  private async submitForm() {
    // All data has been validated and transformed
    console.log('Submitting form:', this.formData);
    // API call here
    return { success: true, id: 'user-123' };
  }
}

// 5. Testing individual rules
describe('Form Field Rules', () => {
  test('email rule validates correctly', () => {
    expect(formRules.email.validate('test@example.com').isValid()).toBe(true);
    expect(formRules.email.validate('invalid').isValid()).toBe(false);
  });

  test('password rule enforces complexity', () => {
    expect(formRules.password.validate('weak').isValid()).toBe(false);
    expect(formRules.password.validate('Strong1@Pass').isValid()).toBe(true);
  });

  test('age rule transforms string to number', () => {
    const result = formRules.age.parse('25');
    expect(result.isValid()).toBe(true);
    expect(typeof result.unwrap()).toBe('number');
    expect(result.unwrap()).toBe(25);
  });
});

API Reference

Registry Methods

Method Returns Description
createPluginRegistry() PluginRegistry Creates new registry instance
use(plugin) PluginRegistry Register a plugin
for<T>() TypedPluginRegistry<T> Create type-safe registry with automatic field inference
createFieldRule(builder, options) FieldRule<T> Create reusable field rule
toBuilder() IChainableBuilder Convert to builder with plugins
getPlugins() Record<string, Plugin> Get all registered plugins

FieldRule Methods

Method Returns Description
validate(value, options?) Result<T> Validate single field value
parse(value, options?) Result<T> Parse with transformations
getPluginRegistry() PluginRegistry Get parent registry

createFieldRule Options

Option Type Description
name string Rule identifier
description string Rule description
fieldOptions FieldOptions Default values and metadata

Best Practices

  • • Create a shared registry module for team-wide consistency
  • • Unit test field rules independently before integration
  • • Use descriptive names and descriptions for field rules
  • • Leverage field options for defaults and metadata
  • • Compose complex rules from simpler, tested components
  • • Document business rules in field rule descriptions