Custom Plugins
Create powerful, type-safe custom validation plugins that integrate seamlessly with Luq's architecture.
π― Why Plugin Categories Matter
Plugin categories aren't just organizational toolsβthey're essential for controlling type variables in ChainableFieldBuilder. Each category determines what type parameters are passed to the builder, ensuring complete type safety.
standard
fieldReference
transform
Category Overview
Why Categories Matter
Plugin categories are fundamental to Luq's type system. They directly control which type parameters are passed to ChainableFieldBuilder, enabling precise type inference and method availability. The category determines the builder's generic signature, which in turn controls what methods are available and how they behave.
Type Variable Control
Here's how categories map to different ChainableFieldBuilder signatures in the actual implementation:
// π― Categories control ChainableFieldBuilder type variables
// Based on the actual ChainableFieldBuilder implementation
export type ChainableFieldBuilder<
TObject extends object, // The complete object type
TPlugins = {}, // Available plugins
TType extends TypeName | undefined = undefined, // Field type name
TCurrentType = TType extends TypeName ? TypeMapping[TType] : any, // Current value type
TTypeState extends TypeStateFlags = {}, // Required/optional/nullable state
> = ...
// How categories affect the signature:
// Standard category: Basic field validation
// β ChainableFieldBuilder<TObject, TPlugins, TType, TCurrentType, TTypeState>
// All type parameters available, no special behavior
// FieldReference category: Compare with other fields
// β Methods get (fieldPath: NestedKeyOf<TObject> & string, ...)
// TObject type enables type-safe field path access
// Transform category: Data transformation
// β Methods get <TOutput>(fn: (value: TCurrentType) => TOutput)
// Returns ChainableFieldBuilder<..., TOutput, ...>
// Changes TCurrentType to TOutput for subsequent methods
// Conditional category: Dynamic validation control
// β Methods get (condition: (allValues: TObject) => boolean, ...)
// TObject type enables condition evaluation against other fields
// MultiFieldReference: Complex cross-field validation
// β Methods get <TFields extends readonly (NestedKeyOf<TObject> & string)[]>(...)
// Type-safe access to multiple fields with compile-time validation
Category Reference
Standard Plugins
Purpose: Basic value validation for individual fields
ChainableFieldBuilder signature: Full signature with all type parameters available
Method signature: Standard parameters ((...args: TArgs)
)
Use Cases: Format validation, range checks, pattern matching
import { plugin } from '@maroonedog/luq';
// Standard category: Basic value validation
const productCodePlugin = plugin({
name: 'productCode',
methodName: 'productCode',
allowedTypes: ['string'] as const,
category: 'standard', // ChainableFieldBuilder<T>
impl: (options?) => ({
check: (value: string) => {
const isValid = value.startsWith('PROD-') && value.length === 10;
return isValid;
},
code: 'productCode',
getErrorMessage: (value, path) =>
`${path} must be a valid product code (PROD-XXXXXX)`
})
});
// Usage
const validator = Builder()
.use(productCodePlugin)
.for<{ code: string }>()
.v('code', b => b.string.required().productCode())
.build();
Field Reference Plugins
Purpose: Validation that compares with other fields
Method signature override: (fieldPath: NestedKeyOf<TObject> & string, options?: ValidationOptions)
Type safety: Field path is validated against the object structure at compile time
Use Cases: Password confirmation, field matching, comparative validation
// Field Reference category: Compare with other fields
const confirmPasswordPlugin = plugin({
name: 'confirmPassword',
methodName: 'confirmPassword',
allowedTypes: ['string'] as const,
category: 'fieldReference', // ChainableFieldBuilder<T, TObject>
impl: (fieldPath: string) => ({
check: (value: string, allValues: any) => {
// Helper to get nested field value
const getValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const otherValue = getValue(allValues, fieldPath);
return value === otherValue;
},
code: 'confirmPassword',
getErrorMessage: () => 'Password confirmation does not match'
})
});
// Usage - type-safe field reference
type UserForm = {
password: string;
confirmPassword: string;
};
const validator = Builder()
.use(confirmPasswordPlugin)
.for<UserForm>()
.v('confirmPassword', b =>
b.string.required().confirmPassword('password') // Type-safe!
)
.build();
Transform Plugins
Purpose: Data transformation after successful validation
Method signature override: <TOutput>(fn: (value: TCurrentType) => TOutput)
Return type change: Returns ChainableFieldBuilder<..., TOutput, ...>
Type flow: Changes TCurrentType
to TOutput
for subsequent methods
Use Cases: Type conversion, normalization, computed values
β οΈ Important: validate() vs parse()
- β’
validate()
- Returns original values (no transformation applied) - β’
parse()
- Returns transformed values (applies all transformations) - β’ Always use
parse()
when you need transformed data - β’ Type system reflects the difference:
validate()
returns original types,parse()
returns transformed types
Luq provides three ways to create transform plugins:
π§ Transform Plugin Creation Methods
1. Generic Transform
User provides transform function at runtime using plugin()
2. Predefined Transform
Fixed transformation with no parameters using pluginPredefinedTransform()
3. Configurable Transform
Transformation with configuration parameters using pluginConfigurableTransform()
// Transform category: Data conversion
const parseIntPlugin = plugin({
name: 'parseInt',
methodName: 'parseInt',
allowedTypes: ['string'] as const,
category: 'transform', // ChainableFieldBuilder<TInput, TOutput>
impl: () => ({
check: (value: string) => {
const num = parseInt(value, 10);
return {
valid: !isNaN(num),
transformedValue: num // string β number
};
},
code: 'parseInt',
getErrorMessage: (value, path) => `${path} must be a valid integer`
})
});
// Usage - transforms string to number
type FormData = { ageString: string };
type ProcessedData = { ageString: number }; // After transform
const validator = Builder()
.use(parseIntPlugin)
.for<FormData>()
.v('ageString', b => b.string.required().parseInt())
.build();
// IMPORTANT: Use parse() to get transformed values, not validate()
const result = validator.parse({ ageString: '25' });
if (result.isValid()) {
const data = result.unwrap();
// data.ageString is now number type!
}
// Note: validate() returns original values, parse() returns transformed values
// validator.validate() β { ageString: '25' } (string)
// validator.parse() β { ageString: 25 } (number)
Predefined Transform Plugins
Use pluginPredefinedTransform
for fixed transformations that don't require parameters:
import { pluginPredefinedTransform } from '@maroonedog/luq';
// Predefined transform: toUpperCase (no parameters)
const toUpperCasePlugin = pluginPredefinedTransform({
name: 'toUpperCase',
allowedTypes: ['string'] as const,
impl: () => (value: string, ctx) => ({
valid: true,
__isTransform: true,
__transformFn: (value: string) => value.toUpperCase()
})
});
// Predefined transform: trim (no parameters)
const trimPlugin = pluginPredefinedTransform({
name: 'trim',
allowedTypes: ['string'] as const,
impl: () => (value: string, ctx) => ({
valid: true,
__isTransform: true,
__transformFn: (value: string) => value.trim()
})
});
// Usage - no parameters required
const validator = Builder()
.use(toUpperCasePlugin)
.use(trimPlugin)
.for<{ name: string }>()
.v('name', b =>
b.string
.required()
.trim() // Fixed transformation
.toUpperCase() // Fixed transformation
)
.build();
// IMPORTANT: Use parse() to get transformed values
const result = validator.parse({ name: ' hello world ' });
if (result.isValid()) {
console.log(result.unwrap().name); // "HELLO WORLD" (trimmed and uppercase)
}
// validate() returns original, parse() returns transformed
// validator.validate() β { name: ' hello world ' }
// validator.parse() β { name: 'HELLO WORLD' }
Configurable Transform Plugins
Use pluginConfigurableTransform
for transformations that accept configuration parameters:
import { pluginConfigurableTransform } from '@maroonedog/luq';
// Configurable transform: split with delimiter
const splitPlugin = pluginConfigurableTransform({
name: 'split',
allowedTypes: ['string'] as const,
impl: (delimiter: string) => (value: string, ctx) => ({
valid: true,
__isTransform: true,
__transformFn: (value: string) => value.split(delimiter)
})
});
// Configurable transform: round to specified decimal places
const roundToPlugin = pluginConfigurableTransform({
name: 'roundTo',
allowedTypes: ['number'] as const,
impl: (decimals: number) => (value: number, ctx) => ({
valid: true,
__isTransform: true,
__transformFn: (value: number) => Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals)
})
});
// Configurable transform: pad string with character
const padStartPlugin = pluginConfigurableTransform({
name: 'padStart',
allowedTypes: ['string'] as const,
impl: (targetLength: number, padString: string = ' ') => (value: string, ctx) => ({
valid: true,
__isTransform: true,
__transformFn: (value: string) => value.padStart(targetLength, padString)
})
});
// Usage with configuration parameters
type FormData = {
tags: string; // "apple,banana,cherry"
price: number; // 19.999
productId: string; // "123"
};
type ProcessedData = {
tags: string[]; // ["apple", "banana", "cherry"]
price: number; // 20.00
productId: string; // "00123"
};
const validator = Builder()
.use(splitPlugin)
.use(roundToPlugin)
.use(padStartPlugin)
.for<FormData>()
.v('tags', b =>
b.string
.required()
.split(',') // Transform: string β string[]
)
.v('price', b =>
b.number
.required()
.roundTo(2) // Transform: 19.999 β 20.00
)
.v('productId', b =>
b.string
.required()
.padStart(5, '0') // Transform: "123" β "00123"
)
.build();
// IMPORTANT: Use parse() to get transformed values
const result = validator.parse({
tags: 'apple,banana,cherry',
price: 19.999,
productId: '123'
});
if (result.isValid()) {
const processed = result.unwrap();
// processed.tags is string[] β ["apple", "banana", "cherry"]
// processed.price is 20.00
// processed.productId is "00123"
}
// Remember the difference:
// validate() β Original types (tags: string, price: number, productId: string)
// parse() β Transformed types (tags: string[], price: number, productId: string)
Conditional Plugins
Purpose: Dynamic validation control based on conditions
Method signature override: (condition: (allValues: TObject) => boolean, options?: ValidationOptions)
Type safety: Condition function receives typed TObject
for safe field access
Use Cases: Conditional requirements, skip logic, dynamic validation
// Conditional category: Dynamic validation control
const requiredWhenPlugin = plugin({
name: 'requiredWhen',
methodName: 'requiredWhen',
allowedTypes: ['string', 'number', 'boolean'] as const,
category: 'conditional', // ChainableFieldBuilder<T, TObject>
impl: (condition: (obj: any) => boolean) => ({
check: (value: any, allValues: any) => {
// Skip validation if condition not met
if (!condition(allValues)) {
return { valid: true, __skipAllValidation: true };
}
// Apply required validation
return value != null && value !== '';
},
code: 'requiredWhen',
getErrorMessage: (value, path) => `${path} is required`
})
});
// Usage - conditional validation based on other fields
type BusinessForm = {
userType: 'personal' | 'business';
companyName?: string;
taxId?: string;
};
const validator = Builder()
.use(requiredWhenPlugin)
.for<BusinessForm>()
.v('companyName', b =>
b.string.requiredWhen(data => data.userType === 'business')
)
.v('taxId', b =>
b.string.requiredWhen(data => data.userType === 'business')
)
.build();
Multi-Field Reference
Purpose: Complex validation involving multiple other fields
Method signature override: <const TFields extends readonly (NestedKeyOf<TObject> & string)[]>(...)
Type safety: Field paths are validated at compile time, field values are properly typed
Advanced feature: Uses FieldsToObject
type to extract and type field values
Use Cases: Date ranges, complex business rules, multi-field constraints
// Multi-Field Reference: Complex cross-field validation
const dateRangePlugin = plugin({
name: 'dateRange',
methodName: 'afterDate',
allowedTypes: ['string'] as const,
category: 'multiFieldReference', // ChainableFieldBuilder<T, TObject>
impl: (fieldPaths: readonly string[]) => ({
check: (endDate: string, allValues: any) => {
const getValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
// Get all referenced field values
const referencedValues = fieldPaths.map(path => getValue(allValues, path));
const startDate = referencedValues[0];
if (!startDate) return true; // Skip if no start date
const start = new Date(startDate);
const end = new Date(endDate);
return end > start;
},
code: 'dateRange',
getErrorMessage: () => 'End date must be after start date'
})
});
// Usage - validates against multiple other fields
type EventForm = {
startDate: string;
endDate: string;
publishDate: string;
};
const validator = Builder()
.use(dateRangePlugin)
.for<EventForm>()
.v('endDate', b =>
b.string.required().afterDate(['startDate'] as const)
)
.v('publishDate', b =>
b.string.required().afterDate(['startDate', 'endDate'] as const)
)
.build();
Context Plugins
Purpose: Validation using external context data
Method signature: Standard signature with context access in implementation
Runtime behavior: Context data passed through validation pipeline (synchronous only)
Use Cases: Pre-loaded data validation, cached lookups, context-aware rules
Note: Async validation is not currently supported
// Context category: External data validation
const uniqueEmailPlugin = plugin({
name: 'uniqueEmail',
methodName: 'uniqueEmail',
allowedTypes: ['string'] as const,
category: 'context', // ChainableFieldBuilder<T, TContext>
impl: () => ({
check: (email: string, allValues: any, context: any) => {
// Use external context for validation (sync only)
const existingEmails = context?.existingEmails || [];
const isUnique = !existingEmails.includes(email);
return isUnique;
},
code: 'uniqueEmail',
getErrorMessage: () => 'Email already exists'
})
});
// Usage with context
const validator = Builder()
.use(uniqueEmailPlugin)
.for<{ email: string }>()
.v('email', b => b.string.required().email().uniqueEmail())
.build();
// Validate with context
const context = {
existingEmails: ['user@example.com', 'admin@example.com']
};
const result = validator.validateWithContext(
{ email: 'new@example.com' },
context
);
Implementation Examples
Complete Examples
Here's a comprehensive example showing how different plugin categories work together:
import { Builder, plugin } from '@maroonedog/luq';
// 1. Create custom plugins for different categories
const businessEmailPlugin = plugin({
name: 'businessEmail',
methodName: 'businessEmail',
allowedTypes: ['string'] as const,
category: 'standard',
impl: () => ({
check: (email: string) => {
const businessDomains = ['company.com', 'business.org', 'corp.net'];
const domain = email.split('@')[1];
return businessDomains.includes(domain);
},
code: 'businessEmail',
getErrorMessage: () => 'Must use a business email domain'
})
});
const passwordMatchPlugin = plugin({
name: 'passwordMatch',
methodName: 'matchesPassword',
allowedTypes: ['string'] as const,
category: 'fieldReference',
impl: () => ({
check: (confirmPassword: string, allValues: any) => {
return confirmPassword === allValues.password;
},
code: 'passwordMatch',
getErrorMessage: () => 'Passwords do not match'
})
});
const normalizeEmailPlugin = plugin({
name: 'normalizeEmail',
methodName: 'normalize',
allowedTypes: ['string'] as const,
category: 'transform',
impl: () => ({
check: (email: string) => ({
valid: true,
transformedValue: email.toLowerCase().trim()
}),
code: 'normalizeEmail'
})
});
// 2. Define your data types
type RegistrationForm = {
email: string;
password: string;
confirmPassword: string;
company: string;
plan: 'basic' | 'premium';
premiumFeatures?: string[];
};
// 3. Build comprehensive validator
const registrationValidator = Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.use(stringMinPlugin)
.use(businessEmailPlugin)
.use(passwordMatchPlugin)
.use(normalizeEmailPlugin)
.use(arrayMinLengthPlugin)
.for<RegistrationForm>()
.v('email', b =>
b.string
.required()
.email()
.businessEmail()
.normalize() // transform: string β normalized string
)
.v('password', b =>
b.string
.required()
.min(8)
)
.v('confirmPassword', b =>
b.string
.required()
.matchesPassword() // fieldReference: compares with password
)
.v('company', b =>
b.string.required().min(2)
)
.v('premiumFeatures', b =>
b.array
.requiredWhen(data => data.plan === 'premium') // conditional
.minLength(1)
)
.build();
// 4. Use the validator
const formData = {
email: ' USER@COMPANY.COM ',
password: 'securePassword123',
confirmPassword: 'securePassword123',
company: 'Acme Corp',
plan: 'premium' as const,
premiumFeatures: ['analytics', 'api-access']
};
const result = registrationValidator.validate(formData);
if (result.isValid()) {
const validatedData = result.unwrap();
// validatedData.email is now normalized: 'user@company.com'
console.log('Registration successful!', validatedData);
} else {
console.log('Validation errors:', result.errors);
}