Skip to content

LIVR: Design Decisions ​

This document examines LIVR's design decisions, compares them to alternatives in other validators, and explains why each choice matters. Every design decision follows a simple formula:

Quality X is a priority → We choose design Y → Accepting trade-off Z


Quick Start ​

typescript
import LIVR from 'livr';
import type { InferFromSchema } from 'livr/types';

const schema = {
  email: ['required', 'email'],
  age: ['required', 'positiveInteger'],
  role: { oneOf: ['admin', 'user'] as const },
} as const;

type User = InferFromSchema<typeof schema>;
// Inferred: { email: string; age: number; role: 'admin' | 'user' }

const validator = new LIVR.Validator<User>(schema);

const result = validator.validate({
  email: 'user@example.com',
  age: '25', // String from form → automatically becomes number
  role: 'admin',
  hackAttempt: true, // Unknown field → automatically stripped
});

if (result) {
  console.log(result.role); // TypeScript knows: 'admin' | 'user'
}
// result: { email: 'user@example.com', age: 25, role: 'admin' }

What just happened?

  • Type inference from schema via InferFromSchema<>
  • String '25' automatically coerced to number 25
  • Unknown hackAttempt field automatically stripped
  • Result is fully typed as User

Note: Examples use JavaScript's camelCase naming convention (positiveInteger, oneOf). The LIVR specification uses underscore notation (positive_integer, one_of). The JavaScript implementation supports both.


Cool Things You Can Do ​

Store Validation Rules in a Database ​

javascript
// Schema is just JSON - store it anywhere
const schema = JSON.parse(await db.getSchema('user-registration'));
const validator = new LIVR.Validator(schema);

Share Rules Across Languages ​

The same schema works in JavaScript, Python, Java, Ruby, and 7 more languages:

json
{ "email": ["required", "email"], "age": ["positiveInteger"] }
python
# Python
validator = LIVR.Validator(schema)
javascript
// JavaScript
const validator = new LIVR.Validator(schema);

Create Domain-Specific Rules ​

javascript
validator.registerAliasedRule({
  name: 'productSku',
  rules: ['required', { like: '^[A-Z]{3}-\\d{6}$' }],
  error: 'INVALID_PRODUCT_SKU', // Semantic error code
});

// Now use it like a built-in rule
const schema = { sku: 'productSku' };

Validate with Async Rules ​

javascript
LIVR.Validator.registerDefaultRules({
  uniqueEmail: () => async (email) => {
    const exists = await db.users.findByEmail(email);
    if (exists) return 'EMAIL_ALREADY_TAKEN';
  },
});

Validate Arrays ​

javascript
const schema = {
  tags: { listOf: 'string' },
  items: {
    listOf: {
      nestedObject: {
        id: ['required', 'positiveInteger'],
        name: 'string',
      },
    },
  },
};

// Input: { tags: ["js", "node"], items: [{ id: 1, name: "Item" }] }

Safe with User-Provided Schemas ​

javascript
// LIVR schemas are data, not code - safe to load from untrusted sources
const schema = JSON.parse(req.body.schema);
const validator = new LIVR.Validator(schema); // No code execution risk

Table of Contents ​

  1. Quick Start
  2. Cool Things You Can Do
  3. Feature Comparison Matrix
  4. Design Decisions
  5. TypeScript Integration
  6. Performance Summary

Feature Comparison Matrix ​

Before diving into design decisions, here's a comprehensive comparison of LIVR against popular alternatives:

FeatureLIVRZodJoifastest-validatorValibot
TypeScript Inference✅ InferFromSchema<>✅ z.infer<>❌ No❌ No✅ InferOutput<>
Async Validation✅ AsyncValidator⚠️ Via parseAsync✅ Yes✅ Yes⚠️ Via pipeAsync
Custom Rules✅ Very Easy✅ .refine()✅ .custom()✅ Yes✅ v.custom()
Rules Aliasing✅ registerAliasedRule()❌ No❌ No⚠️ Partial❌ No
Schema Serializable✅ Pure JSON❌ Code❌ Code⚠️ Partial❌ Code
Multi-Language Support✅ 11 languages❌ JS/TS only❌ JS only❌ JS only❌ JS/TS only
Error FormatCodes (i18n-ready)MessagesMessagesMessagesMessages
Error StructureMirrors inputFlat arrayFlat arrayFlat arrayFlat array
Type Coercion✅ AutomaticManual✅ Via options✅ YesManual
Unknown FieldsStrip (default)Strip (default)Pass throughPass throughStrip (default)
Input Immutability✅ Always✅ Always✅ Always⚠️ Mutates✅ Always
Whole Package (min+gzip)5 KB92 KB55 KB12.4 KB15 KB
Zero Dependencies✅ Yes✅ Yes❌ No✅ Yes✅ Yes
Safe with Untrusted Schemas✅ YesN/AN/A❌ RCE riskN/A

Whole package sizes include everything a package exports; Valibot and Zod are built for tree-shaking, and so is LIVR. See Zero Dependencies for how they were measured.


Design Decisions ​

Design Decision #1: Schemas as Data vs Schemas as Code ​

Portability & Composability is a priority → We choose pure JSON data structures for schemas → Accepting no fluent API or method chaining

Comparison ​

ValidatorApproachSerializableEmbeddable in ConfigMulti-Language
LIVRJSON data✅ Yes✅ Yes✅ Yes
ZodFunction chains❌ No❌ No❌ No
ValibotPipe composition❌ No❌ No❌ No
JoiFluent builder❌ No❌ No❌ No
fastest-validatorObject literals⚠️ Partial⚠️ Partial❌ No

Example ​

LIVR - Schema is pure data:

javascript
// Can be stored in DB, sent over API, loaded from config file
const schema = {
  username: ['required', { minLength: 3 }, { maxLength: 50 }],
  email: ['required', 'email'],
  role: { oneOf: ['admin', 'user', 'guest'] },
};

// Store in database
await db.saveSchema('user-registration', JSON.stringify(schema));

// Load and validate
const loadedSchema = JSON.parse(await db.getSchema('user-registration'));
const validator = new LIVR.Validator(loadedSchema);

Zod - Schema is code:

typescript
// Cannot serialize - this is JavaScript code, not data
const schema = z.object({
  username: z.string().min(3).max(50),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
});

// Cannot store in database
// Cannot send over API
// Cannot share with Python/Java services

Why This Matters: Embedding in Larger Configurations ​

Because LIVR schemas are data, they can be embedded within larger configuration systems:

Form Builder - Single JSON payload with UI + validation:

json
{
  "formId": "contact-form",
  "fields": [
    {
      "name": "email",
      "type": "text",
      "label": "Email Address",
      "placeholder": "you@example.com",
      "validation": ["required", "email"]
    },
    {
      "name": "age",
      "type": "number",
      "label": "Age",
      "validation": ["positiveInteger", { "numberBetween": [18, 120] }]
    }
  ]
}

API Gateway Config - Route definition with validation:

json
{
  "route": "/api/users",
  "method": "POST",
  "auth": "jwt",
  "rateLimit": { "requests": 100, "window": "1m" },
  "validation": {
    "body": {
      "email": ["required", "email"],
      "password": ["required", { "minLength": 8 }]
    }
  }
}

CMS Content Type - Field definitions with validation:

json
{
  "contentType": "BlogPost",
  "fields": {
    "title": {
      "type": "string",
      "required": true,
      "validation": ["required", { "maxLength": 200 }]
    },
    "slug": {
      "type": "string",
      "validation": ["required", { "like": "^[a-z0-9-]+$" }]
    }
  }
}

This is impossible with code-based validators—you cannot embed JavaScript class instances in JSON configurations.

Advanced Use Cases ​

Database-Driven Validation:

javascript
// Database table: validation_schemas
// | entity_type | schema                                    | version |
// |-------------|-------------------------------------------|------------|
// | user        | {"name": ["required"], "email": "email"}  | 3       |
// | product     | {"sku": "productSku", "price": "..."}    | 5       |

async function validateEntity(entityType, data) {
  const { schema } = await db.query(
    'SELECT schema FROM validation_schemas WHERE entity_type = ?',
    [entityType]
  );
  const validator = new LIVR.Validator(JSON.parse(schema));
  return validator.validate(data);
}

Multi-Tenant SaaS Validation:

javascript
// Each tenant has customized validation requirements
const tenantSchemas = {
  'tenant-acme': {
    order: {
      amount: ['required', { maxNumber: 10000 }], // Conservative limit
      approver: 'required', // Requires approval
    },
  },
  'tenant-bigcorp': {
    order: {
      amount: ['required', { maxNumber: 1000000 }], // Higher limit
      // No approver required
    },
  },
};

function validateForTenant(tenantId, entityType, data) {
  const schema = tenantSchemas[tenantId]?.[entityType];
  if (!schema) throw new Error('Unknown entity type');
  return new LIVR.Validator(schema).validate(data);
}

Safe Schema Loading:

LIVR schemas are just data—safe to load from untrusted sources:

javascript
// SAFE: Loading LIVR schema from user input
const userSchema = JSON.parse(req.body.schema);
const validator = new LIVR.Validator(userSchema); // Just data, no code execution

// DANGEROUS: fastest-validator with untrusted schema (real RCE vulnerability)
const Validator = require('fastest-validator');
const v = new Validator();
const check = v.compile(req.body.schema); // RCE if schema contains malicious code!

// Also dangerous: Using eval with any schema (obvious security risk)
// const zodSchema = eval(req.body.schema);  // Never eval untrusted input!

LIVR schemas cannot contain executable code. The worst a malicious schema can do is define invalid rules (which LIVR will reject) or create overly complex validations.


Design Decision #2: No Code Generation (Security) ​

Security is a priority → We choose rule parameters as data, never executed → Accepting no speedups from code generation (static speed is still ahead of code-gen validators)

Comparison ​

ValidatorApproachCode Injection RiskSafe with Untrusted Schemas
LIVRData interpretation❌ Impossible✅ Yes
fastest-validatorCode generation⚠️ RCE vulnerability❌ No
ZodRuntime evaluation❌ No (not serializable)N/A
JoiRuntime evaluation❌ No (not serializable)N/A

The fastest-validator RCE Vulnerability ​

fastest-validator achieves high static performance by compiling schemas to JavaScript code. This creates a dangerous attack vector when schema parameters aren't properly sanitized.

Real vulnerability (GitHub Issue #326):

javascript
const Validator = require('fastest-validator');
const v = new Validator();

// Attacker controls schema (e.g., from database, API, user input)
const maliciousSchema = {
  id: {
    type: 'number',
    max: 'console.log(process.env) || 999', // Injected code!
  },
};

const check = v.compile(maliciousSchema);
check({ id: 123 }); // Executes: console.log(process.env) - RCE!

Why LIVR is Immune ​

LIVR treats schema parameters as data, never as code:

javascript
const LIVR = require('livr');

// Even with malicious input, no code execution
const schema = {
  id: ['required', { maxNumber: 'console.log(process.env) || 999' }],
};

const validator = new LIVR.Validator(schema);
validator.validate({ id: 123 });
// Result: { id: 123 }
// No code execution - the argument is only compared with the value, as a number

When This Matters ​

  • Multi-tenant SaaS: Different customers define their own validation rules
  • No-code platforms: Users build forms with custom validation
  • Database-stored schemas: Schemas loaded from potentially compromised sources
  • API-driven validation: Schemas received from external services

Design Decision #3: Optimized for Both Dynamic AND Static Validation ​

Balanced performance is a priority → We choose lightweight JSON parsing + efficient rule execution → Accepting no extra speed from code generation (LIVR is still the fastest in the benchmarks below)

Dynamic Validation Performance ​

Dynamic validation measures schema creation + validation—critical for runtime-generated schemas.

ValidatorSimple (ops/sec)Nested (ops/sec)vs LIVR
LIVR1,364,823634,178baseline
Valibot921,293534,1611.2-1.5x slower
Joi41,25523,15027-33x slower
Zod15,6508,64973-87x slower
fastest-validator10,6975,946107-128x slower
Ajv1,442804788-946x slower

LIVR leads dynamic validation, Valibot is 1.2-1.5x slower. LIVR schemas are plain JSON data, so creating a validator only builds a closure per rule. fastest-validator and Ajv generate and compile JavaScript code for every schema, which is the slowest option here.

Static Validation Performance ​

Static validation measures pre-compiled validator speed—typical production usage.

ValidatorSimple (ops/sec)Nested (ops/sec)Note
LIVR3,457,6571,764,838Data interpretation
Valibot2,876,8191,535,356
fastest-validator2,857,3961,173,430Code generation
Zod2,794,6471,248,719
Ajv2,513,1331,270,083Code generation
Joi313,462175,847

LIVR is the fastest: Valibot, fastest-validator and Zod are 1.15-1.5x slower, Ajv is about 1.4x slower, Joi about 10x.

The Key Insight ​

3.4 million ops/sec is already far beyond any real-world need.

To put this in perspective:

  • 3.4M ops/sec = validating every request for a service handling 300 billion requests/day
  • Most applications handle thousands, not millions, of validations per second
  • Network latency, database queries, and business logic dominate actual response times

LIVR is the fastest in both dynamic and static validation here, without generating code.

How These Numbers Were Measured ​

Measured with the separate validators-benchmarks repository (npm run bench -- --runs 2, with LIVR_DIR pointing to this repository). Node.js 26, AMD Ryzen 9 5950X; Zod 4.6, Valibot 1.5, Joi 18.3, fastest-validator 1.19, Ajv 8.20. Average of two runs, the runs differ by up to 6%.

  • Simple: a 5-field form (required strings, email, positive integer, enum, min length)
  • Nested: an object with two levels of nested objects and a list of strings
  • Schemas are equivalent across validators (the benchmark checks that all of them accept and reject the same data), and unknown fields are stripped
  • A new input object is created for every validation (fastest-validator and Ajv mutate their input), for all validators

Absolute numbers depend on hardware; compare the ratios.

When Dynamic Performance Matters ​

  • Form builders: Schemas constructed at runtime based on configuration
  • Multi-tenant validation: Different rules per customer, loaded dynamically
  • A/B testing: Different validation strictness across variants
  • API gateways: Route-specific validation loaded from config

Design Decision #4: Zero Dependencies ​

Supply chain security & lightweight deployment is a priority → We choose zero runtime dependencies → Accepting implementing each rule without reusing existing libraries

Comparison ​

ValidatorZero DepsRuntime DepsWhole Package (min+gzip)
LIVRYes05 KB (core: 2 KB)
ValibotYes015 KB
ZodYes092 KB
fastest-validatorYes012.4 KB
AjvNo438 KB
JoiNo7 (six @hapi/* packages)55 KB

Sizes are of everything a package exports, bundled with esbuild (--minify) and gzipped: LIVR 2.11.0, Valibot 1.5, Zod 4.6, fastest-validator 1.19, Ajv 8.20, Joi 18.3. Valibot and Zod are built for tree-shaking, so a typical schema needs only part of them. LIVR can be tree-shaken too: import the core (livr/lib/Validator) and only the rules you use.

Why Zero Dependencies Matters ​

Supply chain security:

  • Every dependency is a potential attack vector
  • Transitive dependencies multiply risk exponentially
  • Notable incidents: event-stream (bitcoin theft), colors.js (intentional corruption), ua-parser-js (cryptominer injection)
  • Joi's 7 dependencies (six of them @hapi/* packages) and Ajv's 4 each represent audit surface

Audit complexity:

  • Zero dependencies = only your code to audit
  • No transitive dependency trees to review
  • Simpler security compliance (SOC2, HIPAA, etc.)

Stability:

  • No breaking changes from upstream packages
  • No version conflicts in dependency resolution
  • No abandoned dependency risk

Additional Benefits: Minimal Bundle Size ​

Serverless/Edge computing:

  • Cold start time directly affected by bundle size
  • Edge functions have strict size limits (Cloudflare Workers: 1MB)
  • Smaller bundles = faster deployments

Frontend applications:

  • Every KB affects page load time
  • Validation often needed client-side for UX
  • 5 KB vs 55 KB is significant for mobile users

Embedded systems:

  • IoT devices have limited memory
  • Validation needed at the edge

Design Decision #5: Formal Specification with Shared Test Suite ​

Cross-platform consistency is a priority → We choose formal spec (livr-spec.org) with JSON test suite → Accepting specification maintenance overhead

Comparison ​

ValidatorHas SpecificationShared Test SuiteLanguage Implementations
LIVR✅ livr-spec.org✅ JSON files11 languages
Zod❌ No❌ NoJS/TS only
Joi❌ No❌ NoJS only
fastest-validator❌ No❌ NoJS only
Valibot❌ No❌ NoJS/TS only

Why a Formal Specification is Possible ​

Because LIVR schemas are data (JSON), not code:

  • The specification is language-agnostic
  • Test cases are JSON files any implementation can run
  • No need to port JavaScript code to other languages

Available Implementations ​

The same schema works identically across all implementations:

LanguagePackage
JavaScript/TypeScriptlivr
PerlValidator::LIVR
PHPvalidator/livr
PythonLIVR
Gogo-livr
Javavalidator-livr
Rubyruby-livr
Erlangliver, olifer
Lualua-livr
RakuLIVR
SwiftLivr

The implementations page of the specification shows how many test suite cases each of them passes.

Benefits ​

  1. Polyglot microservices: Share validation logic across services in different languages
  2. Frontend/backend consistency: Same rules in browser (JS) and server (Python/Java/etc.)
  3. Mobile validation: Identical rules in iOS, Android, and web
  4. Community growth: Anyone can create a new implementation with confidence

Design Decision #6: Error Codes with Mirrored Structure ​

i18n & Domain clarity is a priority → We choose error codes in a structure mirroring input → Accepting less verbose error details (no rule names, expected values, etc.)

Comparison ​

ValidatorError FormatStructureFocusi18n Approach
LIVRCodes (WRONG_EMAIL)Mirrors inputDomain meaningCodes (separate translation)
ZodMessages + metadataFlat arrayImplementationMessages (40+ built-in locales)
JoiMessages + contextFlat arrayImplementationMessages (manual setup)
ValibotIssues arrayFlat arrayImplementationMessages (i18n package)
fastest-validatorMessagesFlat arrayImplementationMessages (manual setup)

Why LIVR's approach is different: All validators offer some form of message customization, but LIVR is the only one that returns error codes instead of messages. This creates a clean separation of concerns:

  • LIVR: Validator returns codes (WRONG_EMAIL) → UI layer translates to localized messages
  • Others: Translation logic is baked into the validator via message customization

With LIVR, you maintain a single translation table mapping codes to messages. Other validators require configuring translations within the validation layer itself. For example:

  • Valibot: The @valibot/i18n package provides translations, but you're translating rule-specific messages—you cannot define custom domain error codes
  • Zod: Error customization allows custom messages and has 40+ built-in locales, but LIVR's aliasing approach (registerAliasedRule with custom error codes) is more powerful for domain-driven validation

Example ​

Input:

javascript
const input = {
  user: {
    email: 'not-an-email',
    profile: {
      age: 'not-a-number',
    },
  },
};

LIVR errors - Mirrors input structure:

javascript
{
  user: {
    email: 'WRONG_EMAIL',
    profile: {
      age: 'NOT_INTEGER'
    }
  }
}

Other validators - Flat array with paths:

javascript
[
  {
    path: ['user', 'email'],
    message: 'Invalid email format',
    code: 'invalid_string',
  },
  {
    path: ['user', 'profile', 'age'],
    message: 'Expected number, received string',
    code: 'invalid_type',
  },
];

Why Mirrored Structure Matters ​

Easy field mapping: Error structure matches input structure exactly. Display errors next to fields trivially:

javascript
// LIVR - direct property access
const emailError = errors.user?.email; // 'WRONG_EMAIL'
const ageError = errors.user?.profile?.age; // 'NOT_INTEGER'

// Others - must search array
const emailError = errors.find((e) => e.path.join('.') === 'user.email')?.message;

Domain-Focused vs Implementation-Focused ​

LIVR tells you WHAT is wrong (domain meaning):

WRONG_EMAIL       - The email is invalid
TOO_SHORT         - The value is too short
INVALID_PRODUCT_SKU - The SKU doesn't match our format

Others tell you WHICH rule failed (implementation detail):

"Invalid email format"
"String must contain at least 8 character(s)"
"Invalid input: expected string to match pattern /^[A-Z]{3}-\d{6}$/"

i18n Benefits ​

Error codes map cleanly to translations:

javascript
// translations/en.json
{
  "WRONG_EMAIL": "Please enter a valid email address",
  "TOO_SHORT": "Must be at least {min} characters",
  "INVALID_PRODUCT_SKU": "SKU format: ABC-123456"
}

// translations/es.json
{
  "WRONG_EMAIL": "Por favor ingrese un correo electrónico válido",
  "TOO_SHORT": "Debe tener al menos {min} caracteres",
  "INVALID_PRODUCT_SKU": "Formato SKU: ABC-123456"
}

With message-based validators, translations are scattered across validator configurations or require complex message extraction.

Clean Separation of Concerns ​

Validation logic belongs in the business layer. Message formatting belongs in the presentation layer. LIVR enforces this separation:

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Validation    │───▶│   Error Codes    │───▶│  Presentation   │
│     Layer       │    │  (WRONG_EMAIL)   │    │    Layer        │
│   (LIVR)        │    │                  │    │  (Messages)     │
└─────────────────┘    └──────────────────┘    └─────────────────┘

Benefits:

  • Backend doesn't dictate UI text: Same API serves web, mobile, and third-party clients
  • Messages can change without code changes: Update translation files, not validators
  • Different clients, different messages: Mobile app might show shorter messages than web

Design Decision #7: Rules Aliasing with Custom Error Codes ​

Domain-driven validation is a priority → We choose rule aliasing with custom error code override → Accepting indirection (alias → underlying rules)

Comparison ​

ValidatorRule AliasingCustom Error CodesDomain Vocabularies
LIVRregisterAliasedRule()Via error propertyYes
fastest-validatorLimitedNoNo
ZodNoVia message onlyNo
JoiNoVia message onlyNo
ValibotNoVia message onlyNo

Creating Domain Vocabularies ​

Aliases compose existing rules into domain-specific validators:

javascript
const validator = new LIVR.Validator(schema);

// E-commerce domain rules
validator.registerAliasedRule({
  name: 'productSku',
  rules: ['required', { like: '^[A-Z]{3}-\\d{6}$' }],
  error: 'INVALID_PRODUCT_SKU', // Custom error code!
});

validator.registerAliasedRule({
  name: 'priceUsd',
  rules: ['required', 'positiveDecimal', { maxNumber: 999999.99 }],
  error: 'INVALID_PRICE',
});

validator.registerAliasedRule({
  name: 'quantity',
  rules: ['required', 'positiveInteger', { maxNumber: 10000 }],
  error: 'INVALID_QUANTITY',
});

// Clean, domain-focused schema
const orderSchema = {
  sku: 'productSku',
  price: 'priceUsd',
  qty: 'quantity',
};

Healthcare Domain Example ​

javascript
validator.registerAliasedRule({
  name: 'patientMrn',
  rules: ['required', { lengthEqual: 10 }, { like: '^MRN\\d{7}$' }],
  error: 'INVALID_MEDICAL_RECORD_NUMBER',
});

validator.registerAliasedRule({
  name: 'diagnosisCode',
  rules: ['required', { like: '^[A-Z]\\d{2}\\.?\\d{0,4}$' }],
  error: 'INVALID_ICD10_CODE',
});

validator.registerAliasedRule({
  name: 'npiNumber',
  rules: ['required', { lengthEqual: 10 }, 'integer'],
  error: 'INVALID_NPI',
});

const patientSchema = {
  mrn: 'patientMrn',
  diagnosis: 'diagnosisCode',
  provider: 'npiNumber',
};

Alias Composition ​

Aliases can reference other aliases, enabling hierarchical rule composition:

javascript
// Base rules
validator.registerAliasedRule({
  name: 'nonEmptyString',
  rules: ['required', 'string', 'trim', { minLength: 1 }],
});

// Domain rules built on base rules
validator.registerAliasedRule({
  name: 'personName',
  rules: ['nonEmptyString', { maxLength: 100 }],
  error: 'INVALID_NAME',
});

validator.registerAliasedRule({
  name: 'companyName',
  rules: ['nonEmptyString', { maxLength: 200 }],
  error: 'INVALID_COMPANY_NAME',
});

// Higher-level aliases building on previous ones
validator.registerAliasedRule({
  name: 'contactEmail',
  rules: ['required', 'email', 'toLc'],
  error: 'INVALID_CONTACT_EMAIL',
});

This layered approach enables:

  • DRY principle: Common patterns defined once
  • Consistency: All name fields share the same base validation
  • Easy maintenance: Update base rule, all dependent rules benefit

Configuration-Driven Aliases ​

Since aliases are defined with plain objects, load them from configuration:

json
{
  "aliases": [
    {
      "name": "productSku",
      "rules": ["required", { "like": "^[A-Z]{3}-\\d{6}$" }],
      "error": "INVALID_SKU"
    },
    {
      "name": "priceUsd",
      "rules": ["required", "positiveDecimal", { "maxNumber": 999999.99 }],
      "error": "INVALID_PRICE"
    }
  ]
}
javascript
const config = JSON.parse(fs.readFileSync('rules-config.json'));
config.aliases.forEach((alias) => validator.registerAliasedRule(alias));

This enables:

  • Non-developers defining validation rules via config files
  • Different rule sets per environment
  • A/B testing validation strictness
  • Multi-tenant rule customization

Design Decision #8: Rules as Transformers (Not Just Validators) ​

Single-pass processing & web form compatibility is a priority → We choose rules that validate AND transform in one pass → Accepting implicit transformation (less explicit than separate transform step)

Comparison ​

ValidatorString TransformersType CoercionApproach
LIVRtrim, toLc, toUcAutomatic (safe)Single pass
Zodtrim(), toLowerCase(), etc.z.coerce.* (explicit)Pipe/chain
Valibottrim, toLowerCase, etc.toNumber(), etc. (explicit)Pipe
Joi.trim(), .lowercase(), etc.convert: true (default)Chain
fastest-validatortrim, lowercaseconvert: trueOptions (mutates!)

Example: Web Form Data ​

HTML forms submit everything as strings. LIVR handles this seamlessly:

javascript
const validator = new LIVR.Validator({
  age: ['required', 'positiveInteger'],
  price: ['required', 'decimal'],
  name: ['required', 'trim'],
  email: ['required', 'email', 'toLc'],
});

// Input from web form (all strings)
const input = {
  age: '25',
  price: '19.99',
  name: '  John Doe  ',
  email: 'JOHN@EXAMPLE.COM',
};

const result = validator.validate(input);
// Output (properly typed):
// {
//   age: 25,              // number (coerced from string)
//   price: 19.99,         // number (coerced from string)
//   name: 'John Doe',     // string (trimmed)
//   email: 'john@example.com'  // string (lowercased)
// }

Built-in Transformers ​

RuleEffect
trimRemove leading/trailing whitespace
toLcConvert to lowercase
toUcConvert to uppercase
defaultProvide default value if missing
removeRemove field from output
leaveOnlyKeep only specified fields

Type Coercion ​

Numeric rules automatically coerce string inputs:

RuleInputOutput
integer"42"42
positiveInteger"100"100
decimal"3.14"3.14
numberBetween"50"50

Reference: LIVR Type Coercing Specification

Secure Coercion: LIVR vs Valibot's Old Approach ​

LIVR's numeric coercion is secure by design. Before transforming a value, it validates the input type:

javascript
// LIVR internal implementation
if (!util.isPrimitiveValue(value)) return 'FORMAT_ERROR';
value += ''; // Only coerce AFTER validating it's a primitive

This prevents dangerous coercions where complex types masquerade as valid strings:

InputOld Valibot coerceLIVR
{}"[object Object]" (passes!)FORMAT_ERROR (rejected)
[]"" (passes!)FORMAT_ERROR (rejected)
[1,2,3]"1,2,3" (passes!)FORMAT_ERROR (rejected)
"42" (string)"42" (valid)42 (number, valid)

Valibot removed implicit coercion in v1.2 due to this security concern, now requiring explicit toNumber(), toString() etc. LIVR has always followed the secure pattern: validate type first, then transform.


Design Decision #9: "Everything is a Rule" Architecture ​

Extensibility & Consistency is a priority → We choose minimal core where built-in and custom rules use identical API → Accepting no special optimizations for built-in rules

Comparison ​

ValidatorCore SizeCustom Rule APIReplace Built-ins
LIVR~4KBSame as built-in✅ Yes
Zod~12KBDifferent (.refine())❌ No
Joi~150KBDifferent (.custom())❌ No
Valibot~1KBDifferent (v.custom())❌ No
fastest-validator~40KBDifferent❌ No

The Uniform Rule API ​

Every rule in LIVR—built-in or custom—follows the same pattern:

javascript
ruleName: (ruleArg) => (value, allValues, outputArr) => {
  // Return error code if invalid
  if (invalid) return 'ERROR_CODE';
  // Optionally transform value
  if (transform) outputArr.push(transformedValue);
};

Creating Custom Rules ​

Simple validation rule:

javascript
LIVR.Validator.registerDefaultRules({
  strongPassword: () => (value) => {
    if (!value) return; // Let 'required' handle empty
    if (value.length < 8) return 'TOO_SHORT';
    if (!/[A-Z]/.test(value)) return 'MISSING_UPPERCASE';
    if (!/[0-9]/.test(value)) return 'MISSING_NUMBER';
    if (!/[!@#$%^&*]/.test(value)) return 'MISSING_SPECIAL';
  },
});

// Use like any built-in rule
const schema = { password: ['required', 'strongPassword'] };

Rule with parameter:

javascript
LIVR.Validator.registerDefaultRules({
  minAge: (minAge) => (value) => {
    if (value === undefined || value === null) return;
    if (value < minAge) return 'TOO_YOUNG';
  },
});

// Use with parameter
const schema = { age: ['required', 'positiveInteger', { minAge: 18 }] };

Rule with transformation:

javascript
LIVR.Validator.registerDefaultRules({
  toDate: () => (value, _, outputArr) => {
    if (!value) return;
    const date = new Date(value);
    if (isNaN(date.getTime())) return 'INVALID_DATE';
    outputArr.push(date); // Transform string to Date object
  },
});

const schema = { birthDate: ['required', 'toDate'] };
// Input: { birthDate: '1990-05-15' }
// Output: { birthDate: Date object }

Why "Everything is a Rule" Matters ​

  1. First-class custom rules: Your rules are not second-class citizens
  2. Replace built-ins: Don't like how email works? Replace it
  3. Consistent debugging: All rules work the same way
  4. Predictable API: One pattern to learn, one pattern to document
  5. Transparent implementation: Inspect code of complex meta rules (like nestedObject, listOf) and use them as examples for your own rules

Async Validation ​

The uniform rule API extends to async rules. Use AsyncValidator for rules that need database lookups or API calls:

javascript
import LIVR from 'livr/async'; // Use async version

// Register an async rule
LIVR.Validator.registerDefaultRules({
  uniqueEmail: () => async (email) => {
    if (!email) return;
    const exists = await db.users.findByEmail(email);
    if (exists) return 'EMAIL_ALREADY_TAKEN';
  },
});

// Use like any other rule
const validator = new LIVR.AsyncValidator({
  email: ['required', 'email', 'uniqueEmail'],
});

// Validate returns a Promise
const result = await validator.validate({ email: 'test@example.com' });

No special syntax—async rules follow the same pattern, just return a Promise.

Rule Isolation for Multi-Tenancy ​

Register rules per-validator instance to prevent cross-tenant rule pollution:

javascript
// Tenant A's validator with custom rules
const tenantAValidator = new LIVR.Validator(schemaA);
tenantAValidator.registerRules({
  tenantAFormat: () => (value) => {
    if (!value) return;
    if (!value.startsWith('ACME-')) return 'INVALID_FORMAT';
  },
});

// Tenant B's validator - cannot use tenantAFormat
const tenantBValidator = new LIVR.Validator(schemaB);
// tenantBValidator doesn't have access to tenantAFormat rule

This enables secure multi-tenant validation where each tenant can have custom rules without affecting others.


Design Decision #10: Input Immutability ​

Predictability & Functional style is a priority → We choose always return new object, never mutate input → Accepting memory allocation overhead (negligible in practice)

Comparison ​

ValidatorInput MutationPredictableSafe Reuse
LIVR❌ Never✅ Yes✅ Yes
fastest-validator⚠️ Mutates with $$strict: 'remove'❌ No❌ No
Zod❌ Never✅ Yes✅ Yes
Joi❌ Never✅ Yes✅ Yes
Valibot❌ Never✅ Yes✅ Yes

Example ​

LIVR - Input preserved:

javascript
const input = {
  name: '  John  ',
  email: 'JOHN@EXAMPLE.COM',
  extraField: 'will be stripped',
};

const result = validator.validate(input);

// Result (new object):
// { name: 'John', email: 'john@example.com' }

// Input UNCHANGED:
console.log(input.extraField); // 'will be stripped' - still there!
console.log(input.name); // '  John  ' - still has whitespace!

fastest-validator - Input mutated:

javascript
const input = {
  name: 'John',
  extraField: 'will be removed',
};

const result = check(input); // With $$strict: 'remove'

// Input MODIFIED:
console.log(input.extraField); // undefined - gone!

Why Immutability Matters ​

  1. Predictable behavior: Functions shouldn't have hidden side effects
  2. Safe reuse: Validate same input with multiple validators
  3. Debugging: Original input preserved for logging/inspection
  4. Functional programming: Fits naturally with immutable data patterns

Design Decision #11: Unknown Fields Stripped by Default ​

Security by default is a priority → We choose always strip unknown fields → Accepting must explicitly allow extra fields if needed

Comparison ​

ValidatorDefault BehaviorSecurityConfiguration Needed
LIVRStrip unknown✅ SecureNone
ZodStrip unknown✅ Secure.passthrough() to allow
JoiPass through⚠️ Risk.options({ stripUnknown: true })
fastest-validatorPass through⚠️ Risk$$strict: 'remove'
ValibotStrip unknown✅ SecureUse passthrough to allow

Example ​

javascript
const validator = new LIVR.Validator({
  username: 'required',
  email: 'email',
});

const input = {
  username: 'john',
  email: 'john@example.com',
  isAdmin: true, // Attacker-injected
  role: 'superuser', // Attacker-injected
  __proto__: { hack: 1 }, // Prototype pollution attempt
};

const result = validator.validate(input);
// { username: 'john', email: 'john@example.com' }
// Malicious fields stripped automatically

Security Benefits ​

  1. Mass assignment protection: Extra fields can't sneak into your data model
  2. Privilege escalation prevention: Fields like isAdmin or role are stripped
  3. Prototype pollution defense: Dangerous properties removed
  4. Defense in depth: Security by default, not by configuration

Safe Defaults Summary ​

LIVR's design choices consistently favor security:

AspectLIVR DefaultWhy It's Secure
Unknown fieldsStrippedMass assignment protection
Missing optional fieldsOmitted from outputNo unexpected nulls/undefineds
Invalid rulesRejected at compile timeFail-fast, no silent failures
Rule isolationSupported per-instanceMulti-tenant security
Schema parametersData, never codeNo code injection possible

TypeScript Integration ​

LIVR fully supports TypeScript type inference using the InferFromSchema<> utility:

typescript
import LIVR from 'livr';
import type { InferFromSchema } from 'livr/types';

const userSchema = {
  id: ['required', 'positiveInteger'],
  username: ['required', { minLength: 3 }, { maxLength: 50 }],
  role: { oneOf: ['admin', 'user', 'guest'] as const },
  settings: {
    nestedObject: {
      theme: { oneOf: ['light', 'dark'] as const },
      notifications: 'boolean',
    },
  },
} as const; // 'as const' required for literal type inference

type User = InferFromSchema<typeof userSchema>;
// Inferred type:
// {
//   id: number;
//   username: string;
//   role?: 'admin' | 'user' | 'guest';
//   settings?: {
//     theme?: 'light' | 'dark';
//     notifications?: boolean;
//   }
// }

const validator = new LIVR.Validator<User>(userSchema);
const input = getUserInput(); // data from API, form, etc.
const result = validator.validate(input);

if (result) {
  // result is typed as User
  console.log(result.role); // TypeScript knows: 'admin' | 'user' | 'guest' | undefined
}

Custom Rules with Type Inference ​

Register custom rule types via module augmentation:

typescript
import LIVR from 'livr';
import type { InferFromSchema } from 'livr/types';
import type { RuleTypeDef } from 'livr/types/inference';

// Declare custom rule types via module augmentation
declare module 'livr/types/inference' {
  interface RuleTypeRegistry {
    toDate: RuleTypeDef<Date, false, false>;
    uuid: RuleTypeDef<string, false, false>;
  }
}

// Register the rules
LIVR.Validator.registerDefaultRules({
  toDate: () => (value, _, outputArr) => {
    if (!value) return;
    const date = new Date(value);
    if (isNaN(date.getTime())) return 'INVALID_DATE';
    outputArr.push(date);
  },
  uuid: () => (value) => {
    if (!value) return;
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    if (!uuidRegex.test(value)) return 'INVALID_UUID';
  },
});

const schema = {
  id: ['required', 'uuid'],
  createdAt: ['required', 'toDate'],
} as const;

type Entity = InferFromSchema<typeof schema>;
// { id: string; createdAt: Date }

Performance Summary ​

Dynamic Validation (Schema Creation + Validation) ​

Best for: Runtime-generated schemas, form builders, multi-tenant validation

ValidatorSimpleNestedRelative
LIVR1,364,823634,1781x
Valibot921,293534,1611.2-1.5x slower
Joi41,25523,15027-33x slower
Zod15,6508,64973-87x slower
fastest-validator10,6975,946107-128x slower
Ajv1,442804788-946x slower

Static Validation (Pre-compiled) ​

Best for: Fixed schemas, API endpoints, production validation

ValidatorSimpleNestedNote
LIVR3,457,6571,764,838
Valibot2,876,8191,535,356
fastest-validator2,857,3961,173,430Code generation
Zod2,794,6471,248,719
Ajv2,513,1331,270,083Code generation
Joi313,462175,847

See How These Numbers Were Measured.

The Bottom Line ​

  • Dynamic validation: the fastest: 1.2-1.5x faster than Valibot, 27-33x faster than Joi, 73-87x faster than Zod, 107-128x faster than fastest-validator, 788-946x faster than Ajv
  • Static validation: the fastest: 1.15-1.5x faster than Valibot, fastest-validator and Zod, 1.4x faster than Ajv, 10x faster than Joi
  • Balance: the fastest in both modes, without code generation

Resources ​