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
ChainableFieldBuilder<T>
Basic field validation
fieldReference
ChainableFieldBuilder<T, TObject>
Cross-field validation
transform
ChainableFieldBuilder<TIn, TOut>
Data transformation

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);
}