Builder API

The Builder API is the core of Luq's validation system. It provides a fluent, type-safe interface for constructing validators with plugins and field definitions.

Overview

The Builder follows a fluent API pattern with these key steps:

  1. Create - Initialize a new Builder instance
  2. Configure - Add plugins with use()
  3. Type - Set the validation target type with for<T>()
  4. Define - Add field validations with v()
  5. Build - Create the final validator with build()

Creating a Builder

import { Builder } from '@maroonedog/luq/core';

// Create a new builder instance
const builder = Builder();

The Builder() function creates a new builder instance. It's a factory function that returns a chainable builder object.

use() - Adding Plugins

The use() method adds plugins to the builder. Plugins provide validation methods that become available on field builders.

import { 
  requiredPlugin, 
  stringMinPlugin, 
  numberMinPlugin,
  stringEmailPlugin 
} from '@maroonedog/luq/plugin';

// Add plugins one by one
const builder = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(numberMinPlugin)
  .use(stringEmailPlugin);

// Or add multiple plugins at once
const builder = Builder()
  .use(
    requiredPlugin,
    stringMinPlugin,
    numberMinPlugin,
    stringEmailPlugin
  );

💡 Plugin Loading

  • • Plugins must be added before calling for()
  • • Each plugin is loaded only once (duplicates are ignored)
  • • Plugins determine available validation methods
  • • Order doesn't matter for most plugins

for() - Setting Type

The for<T>() method sets the TypeScript type that the validator will validate. This enables type-safe field paths and type inference.

type UserProfile = {
  name: string;
  age: number;
  email: string;
  preferences?: {
    theme: 'light' | 'dark';
    notifications: boolean;
  };
};

// Specify the type your validator will validate
const builder = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .for<UserProfile>(); // Type parameter sets the validation target

Field Definition Methods

v() - Define Field

The v() method (short for "validate") defines validation rules for a field. It takes a field path and a builder function.

// Basic field definition
builder.v('name', b => b.string.required().min(2));

// Nested field using dot notation
builder.v('preferences.theme', b => b.string.oneOf(['light', 'dark']));

// Array element validation
builder.v('tags[*]', b => b.string.required().min(1));

// Complex validation chain
builder.v('email', b => 
  b.string
    .required()
    .email()
    .transform(email => email.toLowerCase())
);

📝 Field Path Syntax

  • fieldName - Top-level field
  • nested.field.path - Nested object fields
  • array[*] - All array elements
  • nested.array[*].field - Fields in array objects

Field Options

The v() method accepts an optional third parameter for field-specific configuration:

interface FieldOptions<T> {
  default?: T | (() => T);      // Default value when undefined/null
  applyDefaultToNull?: boolean; // Apply default to null (default: true)
  description?: string;          // Field description for documentation
  deprecated?: boolean | string; // Mark field as deprecated
  metadata?: Record<string, any>; // Custom metadata
}

// Using field options with default value
builder.v('theme',
  b => b.string.optional(),
  {
    default: 'light',
    description: 'User theme preference',
    metadata: { group: 'preferences' }
  }
);

// Shorthand syntax - just default value
builder.v('language',
  b => b.string.optional(),
  'en' // Default value shorthand
);

// Function as default value
builder.v('timestamp',
  b => b.number.optional(),
  () => Date.now() // Dynamic default
);

// With deprecated field
builder.v('oldField',
  b => b.string.optional(),
  {
    deprecated: 'Use newField instead',
    default: ''
  }
);

useField() - Reuse Rules

The useField() method allows you to reuse pre-defined field rules across multiple fields. Field rules are created using the Plugin Registry's createFieldRule() method:

import { createPluginRegistry } from '@maroonedog/luq/core/registry';
import { 
  requiredPlugin, 
  stringEmailPlugin,
  stringMinPlugin 
} from '@maroonedog/luq/plugin';

// Create registry with plugins
const registry = createPluginRegistry()
  .register(requiredPlugin)
  .register(stringEmailPlugin)
  .register(stringMinPlugin);

// Create reusable field rules using registry
const emailRule = registry.createFieldRule<string>(
  b => b.string.required().email(),
  { 
    name: 'email', 
    description: 'Valid email address',
    fieldOptions: {
      default: '',
      metadata: { validation: 'strict' }
    }
  }
);

const passwordRule = registry.createFieldRule<string>(
  b => b.string.required().min(8),
  { name: 'password', description: 'Secure password' }
);

// Use field rules in builder
const builder = registry
  .toBuilder()
  .for<{ email: string; password: string; backupEmail: string }>()
  .useField('email', emailRule)
  .useField('backupEmail', emailRule)
  .useField('password', passwordRule);

📌 About FieldRule

  • • FieldRule objects are created through registry.createFieldRule()
  • • They encapsulate validation logic and field options
  • • Each rule can be tested individually with rule.validate(value)
  • • Rules are reusable across multiple fields and validators
  • • See the Plugin Registry API for details

Builder Modes

strict() Mode

The strict() method enables compile-time type checking to ensure all fields in the type have validation rules defined. When fields are missing, it shows errors in your editor/IDE and prevents compilation:

type User = {
  id: string;
  name: string;
  email: string;
  age: number;
};

// Without strict mode - no type checking for missing fields
const validator = Builder()
  .use(requiredPlugin)
  .for<User>()
  .v('name', b => b.string.required())
  .v('email', b => b.string.required())
  .build(); // Missing 'id' and 'age' - builds successfully

// With strict mode - type error if fields are missing
const strictValidator = Builder()
  .use(requiredPlugin)
  .for<User>()
  .v('id', b => b.string.required())
  .v('name', b => b.string.required())
  .v('email', b => b.string.required())
  .v('age', b => b.number.required())
  .strict() // ✅ All fields defined - returns builder
  .build();

// Attempting strict with missing fields
const incompleteValidator = Builder()
  .use(requiredPlugin)
  .for<User>()
  .v('name', b => b.string.required())
  .strict() // ❌ Type error: Returns error object instead of builder
  .build(); // TypeScript error: Property 'build' does not exist on type

// Can continue adding validations after strict()
const continueAfterStrict = Builder()
  .use(requiredPlugin, stringMinPlugin)
  .for<User>()
  .v('id', b => b.string.required())
  .v('name', b => b.string.required())
  .v('email', b => b.string.required())
  .v('age', b => b.number.required())
  .strict() // All fields present - returns builder
  .v('name', b => b.string.required().min(2)) // Can add more validations
  .build(); // Works fine

💡 Strict Mode Behavior

  • Editor Warning: Shows type errors in your editor/IDE when fields are missing
  • Compile-Time Check: Prevents TypeScript compilation if not all fields have validators
  • Return Type: Uses conditional types - returns error type if fields missing, builder if complete
  • Continuable: If all fields are present, you can continue chaining more validations after strict()
  • Position Flexible: Can be called at any point in the chain, not just at the end
  • NO Runtime Effect: Does NOT validate for extra/undefined fields at runtime
  • Error Prevention: Error type lacks build() method, preventing incomplete validators from being built

📝 Note

There's also a strictOnEditor() method which is just an alias for strict(). They are identical - use strict() as it's more concise.

⚠️ Common Misconception

strict() does NOT validate for extra properties at runtime. It only ensures all type fields have validators at compile time.

To reject extra properties at runtime, use the objectAdditionalProperties plugin with additionalProperties(false):

// To reject extra properties at runtime, use objectAdditionalProperties
import { objectAdditionalPropertiesPlugin } from '@maroonedog/luq/plugin';

type User = {
  name: string;
  age: number;
  email: string;
};

const validator = Builder()
  .use(requiredPlugin)
  .use(objectAdditionalPropertiesPlugin)
  .for<{ user: User }>()
  .v('user', b => b.object.additionalProperties(false, {
    allowedProperties: ['name', 'age', 'email']
  }))
  .build();

// This will fail validation
const result = validator.validate({
  user: {
    name: 'John',
    age: 30,
    email: 'john@example.com',
    extra: 'field' // ❌ Extra property not allowed
  }
});

build() - Create Validator

The build() method finalizes the builder and returns a validator instance:

// build() creates the final validator instance
const validator = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .for<UserProfile>()
  .v('name', b => b.string.required().min(2))
  .v('age', b => b.number.required().min(18))
  .build();

// The validator has two main methods:
// 1. validate() - validates and returns original values
const validateResult = validator.validate({
  name: 'John',
  age: 25
});

// 2. parse() - validates and returns transformed values
const parseResult = validator.parse({
  name: 'John',
  age: 25
});

⚠️ validate() vs parse()

  • validate() - Returns original values (no transformations)
  • parse() - Returns transformed values (applies all transformations)
  • • Use parse() when you have transform plugins

Type Inference

Method Chaining

The Builder uses advanced TypeScript features to provide complete type safety and inference throughout the chain:

type FormData = {
  username: string;
  age: string;  // String from form input
  tags: string; // Comma-separated string
};

type ProcessedData = {
  username: string;    // Stays string
  age: number;        // Transformed to number
  tags: string[];     // Transformed to array
};

const validator = Builder()
  .use(requiredPlugin)
  .use(transformPlugin)
  .for<FormData>()
  .v('username', b => b.string.required())
  .v('age', b => b.string.required().transform(v => parseInt(v, 10)))
  .v('tags', b => b.string.required().transform(v => v.split(',')))
  .build();

// TypeScript knows the types
const result = validator.parse({ 
  username: 'john',
  age: '25',
  tags: 'js,ts,react'
});

if (result.isValid()) {
  const data = result.unwrap();
  // data.age is number
  // data.tags is string[]
}

Complete Example

Here's a comprehensive example showing all Builder API features:

import { Builder } from '@maroonedog/luq/core';
import {
  requiredPlugin,
  optionalPlugin,
  stringMinPlugin,
  stringMaxPlugin,
  stringEmailPlugin,
  stringPatternPlugin,
  numberMinPlugin,
  numberMaxPlugin,
  arrayMinLengthPlugin,
  arrayMaxLengthPlugin,
  objectPlugin,
  oneOfPlugin,
  compareFieldPlugin,
  transformPlugin
} from '@maroonedog/luq/plugin';

// Define your data types
type RegistrationForm = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: string;  // From form input
  acceptTerms: boolean;
  preferences?: {
    newsletter: boolean;
    theme: 'light' | 'dark' | 'auto';
  };
  interests: string[];
};

// Build comprehensive validator
const registrationValidator = Builder()
  // Add all required plugins
  .use(
    requiredPlugin,
    optionalPlugin,
    stringMinPlugin,
    stringMaxPlugin,
    stringEmailPlugin,
    stringPatternPlugin,
    numberMinPlugin,
    arrayMinLengthPlugin,
    arrayMaxLengthPlugin,
    objectPlugin,
    oneOfPlugin,
    compareFieldPlugin,
    transformPlugin
  )
  // Set the type
  .for<RegistrationForm>()
  // Define field validations
  .v('username', b => 
    b.string
      .required()
      .min(3)
      .max(20)
      .pattern(/^[a-zA-Z0-9_]+$/)
  )
  .v('email', b => 
    b.string
      .required()
      .email()
      .transform(email => email.toLowerCase())
  )
  .v('password', b => 
    b.string
      .required()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
  )
  .v('confirmPassword', b => 
    b.string
      .required()
      .compareField('password')
  )
  .v('age', b => 
    b.string
      .required()
      .transform(v => parseInt(v, 10))
      .min(13)
      .max(120)
  )
  .v('acceptTerms', b => 
    b.boolean
      .required()
      .equals(true)
  )
  .v('preferences', b => 
    b.object.optional()
  )
  .v('preferences.newsletter', b => 
    b.boolean.optional()
  )
  .v('preferences.theme', b => 
    b.string
      .optional()
      .oneOf(['light', 'dark', 'auto'])
  )
  .v('interests', b => 
    b.array
      .required()
      .minLength(1)
      .maxLength(10)
  )
  .v('interests[*]', b => 
    b.string.required().min(2)
  )
  // Ensure all required fields are defined
  .strict()
  // Build the final validator
  .build();

// Use the validator
const formData = {
  username: 'john_doe',
  email: 'JOHN@EXAMPLE.COM',
  password: 'SecurePass123',
  confirmPassword: 'SecurePass123',
  age: '25',
  acceptTerms: true,
  preferences: {
    newsletter: true,
    theme: 'dark' as const
  },
  interests: ['programming', 'music', 'sports']
};

// Validate and transform
const result = registrationValidator.parse(formData);

if (result.isValid()) {
  const validatedData = result.unwrap();
  console.log('Registration successful!', validatedData);
  // validatedData.email is 'john@example.com' (lowercased)
  // validatedData.age is 25 (number)
} else {
  console.log('Validation errors:', result.errors);
}

API Reference

Method Returns Description
Builder() IChainableBuilder Creates new builder instance
use(...plugins) IChainableBuilder Adds plugins to builder
for<T>() FieldBuilder Sets validation target type
v(path, builder, options?) FieldBuilder Defines field validation
useField(path, rule) FieldBuilder Uses pre-defined rule
strict() FieldBuilder | Error Type-check all fields defined
build() Validator Creates final validator