Validator API

The Validator is the runtime validation engine created by the Builder. It provides methods to validate data and handle results using a functional Result pattern.

Overview

A Validator instance is created by calling .build() on a configured Builder. The validator provides two main methods:

  • validate() - Validates data and returns original values
  • parse() - Validates data and returns transformed values

Both methods return a Result<T> object that provides safe, functional error handling.

Creating a Validator

import { Builder } from '@maroonedog/luq/core';
import { 
  requiredPlugin, 
  stringMinPlugin,
  numberMinPlugin,
  transformPlugin 
} from '@maroonedog/luq/plugin';

// Define your type
type UserData = {
  name: string;
  age: number;
  email: string;
};

// Create validator using Builder
const validator = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(numberMinPlugin)
  .for<UserData>()
  .v('name', b => b.string.required().min(2))
  .v('age', b => b.number.required().min(18))
  .v('email', b => b.string.required())
  .build(); // Returns a Validator instance

Validation Methods

validate() Method

The validate() method checks if data conforms to the schema and returns the original values without applying any transformations:

// validate() returns original values (no transformations applied)
const result = validator.validate({
  name: 'John Doe',
  age: 25,
  email: 'john@example.com'
});

// Check if validation passed
if (result.isValid()) {
  const data = result.unwrap();
  console.log('Valid data:', data);
  // data type is UserData with original values
} else {
  console.log('Validation errors:', result.errors);
}

// Using the 'valid' property (backward compatibility)
if (result.valid) {
  console.log('Valid!');
}

💡 When to use validate()

  • • When you only need to check if data is valid
  • • When you want to preserve original values
  • • When transformations are not needed
  • • For validation-only scenarios

parse() Method

The parse() method validates data AND applies all transformations, returning the transformed values:

// Define a validator with transformations
const transformValidator = Builder()
  .use(requiredPlugin)
  .use(transformPlugin)
  .for<{ email: string; age: string }>()
  .v('email', b => b.string.required()
    .transform(email => email.toLowerCase().trim()))
  .v('age', b => b.string.required()
    .transform(age => parseInt(age, 10)))
  .build();

// parse() applies transformations and returns transformed values
const result = transformValidator.parse({
  email: '  JOHN@EXAMPLE.COM  ',
  age: '25'
});

if (result.isValid()) {
  const data = result.unwrap();
  // data.email is 'john@example.com' (lowercased and trimmed)
  // data.age is 25 (number, not string)
}

📝 When to use parse()

  • • When you have transform plugins in your validator
  • • When you need normalized/sanitized data
  • • When converting types (e.g., string to number)
  • • For data processing pipelines

pick() Method

The pick() method extracts a single field validator from a built validator, enabling individual field validation:

// Extract single field validator from a built validator
const userValidator = Builder()
  .use(requiredPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .use(numberMinPlugin)
  .for<UserProfile>()
  .v('email', b => b.string.required().email())
  .v('username', b => b.string.required().min(3))
  .v('age', b => b.number.required().min(18))
  .v('profile.bio', b => b.string.optional().max(500))
  .build();

// Use pick() to extract a single field validator
const emailValidator = userValidator.pick('email');
const ageValidator = userValidator.pick('age');
const bioValidator = userValidator.pick('profile.bio'); // Nested fields supported

// Validate individual fields
const emailResult = emailValidator.validate('test@example.com');
if (emailResult.valid) {
  console.log('Valid email:', emailResult.value);
}

const ageResult = ageValidator.validate(25);
if (ageResult.valid) {
  console.log('Valid age:', ageResult.value);
}

// Picked validators are type-safe
// emailValidator is FieldValidator<UserProfile, string>
// ageValidator is FieldValidator<UserProfile, number>
// bioValidator is FieldValidator<UserProfile, string | undefined>

// Can provide context (other field values) for field dependencies
const bioResult = bioValidator.validate('My bio text', {
  username: 'john_doe',
  age: 25
});

🎯 When to use pick()

  • • When you need to validate individual fields in isolation
  • • For real-time field validation in forms
  • • When testing specific field validation logic
  • • As an alternative to Plugin Registry for single field validation

💡 Tip: If you already have a validator, use pick() instead of creating a separate Plugin Registry for single field validation.

Result Object

Both validation methods return a Result<T> object that encapsulates the validation outcome. This provides a type-safe, functional approach to error handling.

Result Methods

const result = validator.validate(inputData);

// Check validity
if (result.isValid()) {
  // Successfully validated
}

if (result.isError()) {
  // Validation failed
}

// Get data with different strategies
const data1 = result.unwrap();           // Throws if invalid
const data2 = result.unwrapOr(defaultData); // Returns default if invalid
const data3 = result.unwrapOrElse(
  errors => computeDefault(errors)       // Lazy default computation
);

// Direct access (use with caution)
const maybeData = result.data();         // T | undefined
const errors = result.errors;            // ValidationError[]

Error Handling

When validation fails, the Result object provides access to detailed error information:

// ValidationError structure
interface ValidationError {
  path: string;    // Field path like 'user.email'
  message: string; // Human-readable error message
  code: string;    // Error code like 'required' or 'min_length'
}

// Handling validation errors
const result = validator.validate(invalidData);

if (!result.isValid()) {
  // Access all errors
  result.errors.forEach(error => {
    console.log(`${error.path}: ${error.message} (${error.code})`);
  });
  
  // Use tapError for side effects
  result.tapError(errors => {
    logValidationErrors(errors);
  });
  
  // Or throw exception
  try {
    result.unwrap(); // Throws LuqValidationException
  } catch (e) {
    if (e.name === 'LuqValidationException') {
      console.log('Validation failed:', e.message);
      console.log('Errors:', e.errors);
    }
  }
}

Functional Methods

The Result object supports functional programming patterns for composing and transforming results:

// Transform valid data
const transformed = result
  .map(data => ({
    ...data,
    timestamp: Date.now()
  }))
  .map(data => normalizeData(data));

// Chain validations
const finalResult = validator1.validate(data)
  .flatMap(validData => validator2.validate(validData))
  .flatMap(validData => validator3.validate(validData));

// Side effects without changing the result
const result = validator.validate(data)
  .tap(validData => {
    console.log('Valid:', validData);
    saveToDatabase(validData);
  })
  .tapError(errors => {
    console.error('Invalid:', errors);
    logErrors(errors);
  });

// Convert to plain object (for JSON serialization)
const plain = result.toPlainObject();
// { valid: boolean, data?: T, errors: ValidationError[] }

Validation Options

Both validate() and parse() accept an optional second parameter for configuration:

// ValidationOptions interface
interface ValidationOptions {
  abortEarly?: boolean;  // Stop at first error (default: false)
  context?: any;         // Additional context for validation
}

// Using validation options
const result = validator.validate(data, {
  abortEarly: true,  // Stop at first error
  context: {
    user: currentUser,
    timestamp: Date.now()
  }
});

// Options are passed to all field validators
// Useful for conditional validation based on context

Complete Examples

Form Validation with Transformations

// Form validation example
import { Builder } from '@maroonedog/luq/core';
import { 
  requiredPlugin,
  stringMinPlugin,
  stringEmailPlugin,
  transformPlugin,
  compareFieldPlugin
} from '@maroonedog/luq/plugin';

type RegistrationForm = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: string; // From form input
};

const registrationValidator = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(stringEmailPlugin)
  .use(transformPlugin)
  .use(compareFieldPlugin)
  .for<RegistrationForm>()
  .v('username', b => b.string.required().min(3))
  .v('email', b => b.string.required().email())
  .v('password', b => b.string.required().min(8))
  .v('confirmPassword', b => b.string.required().compareField('password'))
  .v('age', b => b.string.required().transform(v => parseInt(v, 10)))
  .build();

// Handle form submission
function handleSubmit(formData: RegistrationForm) {
  const result = registrationValidator.parse(formData);
  
  return result
    .map(data => {
      // age is now a number
      return createUser(data);
    })
    .tap(user => {
      console.log('User created:', user.id);
    })
    .tapError(errors => {
      displayFormErrors(errors);
    });
}

API Endpoint Validation

// API validation with error response
import { Result } from '@maroonedog/luq';

async function updateUserEndpoint(req: Request): Promise<Response> {
  const body = await req.json();
  
  const result = userUpdateValidator.validate(body);
  
  // Early return for validation errors
  if (!result.isValid()) {
    return new Response(JSON.stringify({
      success: false,
      errors: result.errors.map(e => ({
        field: e.path,
        message: e.message,
        code: e.code
      }))
    }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  // Process valid data
  const userData = result.unwrap();
  const updated = await updateUser(userData);
  
  return new Response(JSON.stringify({
    success: true,
    data: updated
  }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  });
}

Composing Multiple Validators

// Composing multiple validators
const addressValidator = Builder()
  .use(requiredPlugin)
  .for<Address>()
  .v('street', b => b.string.required())
  .v('city', b => b.string.required())
  .v('zipCode', b => b.string.required())
  .build();

const userValidator = Builder()
  .use(requiredPlugin)
  .for<User>()
  .v('name', b => b.string.required())
  .v('email', b => b.string.required())
  .build();

// Compose validators
function validateComplete(data: any): Result<CompleteData> {
  const userResult = userValidator.validate(data.user);
  const addressResult = addressValidator.validate(data.address);
  
  if (!userResult.isValid()) return userResult;
  if (!addressResult.isValid()) return addressResult;
  
  return Result.ok({
    user: userResult.unwrap(),
    address: addressResult.unwrap()
  });
}

API Reference

Validator Methods

Method Returns Description
validate(value, options?) Result<T> Validates and returns original values
parse(value, options?) Result<T> Validates and returns transformed values
pick(fieldName) FieldValidator<T, F> Extracts single field validator

Result Methods

Method Returns Description
isValid() boolean Check if validation passed
isError() boolean Check if validation failed
unwrap() T Get data or throw exception
unwrapOr(default) T Get data or return default
unwrapOrElse(fn) T Get data or compute default
map(fn) Result<U> Transform valid data
flatMap(fn) Result<U> Chain result-returning functions
tap(fn) Result<T> Side effect for valid data
tapError(fn) Result<T> Side effect for errors
data() T | undefined Get data or undefined
errors ValidationError[] Get validation errors
toPlainObject() object Convert to plain object

FieldValidator Methods (from pick())

Method Returns Description
validate(value, allValues?, options?) ValidationResult Validates single field value
value Field type The field value to validate
allValues Partial<T> Context with other field values