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:
- Single Field Validation - Create and test individual field validation rules in isolation (when Builder is not available)
- Field Rule Reusability - Pre-define validation rules that can be shared across multiple forms and validators
- 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']>
notcreateFieldRule<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