DEV Community

Cover image for JSON Schema with AJV: Implementation Deep Dive ⚡
Vishwajeet Kondi
Vishwajeet Kondi

Posted on

JSON Schema with AJV: Implementation Deep Dive ⚡

Part 2 of 3: Getting Your Hands Dirty

Welcome back! In Part 1, we covered the basics of JSON Schema and why it's awesome. Now it's time to roll up our sleeves and implement some real validation with AJV (Another JSON Schema Validator) – the Swiss Army knife of JSON validation.

Why AJV Rules the Validation World

AJV isn't just another validation library – it's THE validation library. Here's why developers love it:

  • Blazingly Fast: Compiles schemas to optimized JavaScript functions
  • Fully Compliant: Supports JSON Schema draft-07 and draft 2019-09
  • Extensible: Custom keywords, formats, and error messages
  • TypeScript Ready: Excellent TypeScript support out of the box

Getting Started: Installation and Basic Setup

# The essentials
npm install ajv

# Optional but recommended additions
npm install ajv-formats ajv-errors ajv-keywords
Enter fullscreen mode Exit fullscreen mode
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

// Create your AJV instance
const ajv = new Ajv({ 
  allErrors: true,        // Collect all errors, not just the first
  removeAdditional: true, // Remove additional properties
  useDefaults: true       // Apply default values
});

// Add common formats (email, date, uri, etc.)
addFormats(ajv);

// Now you're ready to validate!
Enter fullscreen mode Exit fullscreen mode

Your First Real Validation

Let's build something practical – a user registration validator:

const userRegistrationSchema = {
  type: "object",
  properties: {
    username: {
      type: "string",
      minLength: 3,
      maxLength: 20,
      pattern: "^[a-zA-Z0-9_]+$"
    },
    email: {
      type: "string",
      format: "email"
    },
    password: {
      type: "string",
      minLength: 8,
      pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]"
    },
    age: {
      type: "number",
      minimum: 13,
      maximum: 120
    },
    preferences: {
      type: "object",
      properties: {
        newsletter: { type: "boolean", default: false },
        theme: { enum: ["light", "dark"], default: "light" }
      },
      additionalProperties: false
    }
  },
  required: ["username", "email", "password", "age"],
  additionalProperties: false
};

// Compile the schema (do this once, reuse many times)
const validateUser = ajv.compile(userRegistrationSchema);

// Test it out
function registerUser(userData: any) {
  const valid = validateUser(userData);

  if (!valid) {
    console.log('Validation failed:');
    validateUser.errors?.forEach(error => {
      console.log(`- ${error.instancePath}: ${error.message}`);
    });
    return null;
  }

  console.log('✅ User data is valid!');
  return userData; // Now with defaults applied!
}

// Try it
const newUser = {
  username: "coolguy123",
  email: "cool@example.com",
  password: "SuperSecret123!",
  age: 25
};

registerUser(newUser);
// ✅ User data is valid!
// Note: preferences.newsletter and preferences.theme get default values
Enter fullscreen mode Exit fullscreen mode

Advanced AJV Features That'll Blow Your Mind

1. Custom Keywords

Sometimes the built-in validation isn't enough. Let's create a custom keyword:

// Add a custom "isAdult" keyword
ajv.addKeyword({
  keyword: 'isAdult',
  type: 'number',
  schemaType: 'boolean',
  compile(schemaVal) {
    return function validate(data) {
      if (schemaVal) {
        return data >= 18;
      }
      return true;
    }
  },
  errors: false,
  metaSchema: {
    type: "boolean"
  }
});

// Use it in a schema
const adultSchema = {
  type: "object",
  properties: {
    age: { 
      type: "number", 
      isAdult: true 
    }
  }
};

const validateAdult = ajv.compile(adultSchema);
console.log(validateAdult({ age: 17 })); // false
console.log(validateAdult({ age: 21 })); // true
Enter fullscreen mode Exit fullscreen mode

2. Conditional Validation

Real-world data often has complex relationships. AJV handles this beautifully:

const conditionalSchema = {
  type: "object",
  properties: {
    userType: { enum: ["admin", "user", "guest"] },
    permissions: { 
      type: "array",
      items: { type: "string" }
    },
    adminKey: { type: "string" }
  },
  required: ["userType"],

  // If user is admin, they need permissions and adminKey
  if: { 
    properties: { 
      userType: { const: "admin" } 
    } 
  },
  then: { 
    required: ["permissions", "adminKey"],
    properties: {
      permissions: { minItems: 1 }
    }
  },

  // If user is guest, no additional requirements
  else: {
    if: { 
      properties: { 
        userType: { const: "guest" } 
      } 
    },
    then: {
      // Guests can't have permissions
      not: {
        required: ["permissions"]
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

3. Schema Composition with $ref

Keep your schemas DRY and maintainable:

const schemas = {
  // Base address schema
  address: {
    $id: "https://example.com/schemas/address.json",
    type: "object",
    properties: {
      street: { type: "string" },
      city: { type: "string" },
      zipCode: { 
        type: "string", 
        pattern: "^[0-9]{5}(-[0-9]{4})?$" 
      }
    },
    required: ["street", "city", "zipCode"]
  },

  // Person schema using address
  person: {
    $id: "https://example.com/schemas/person.json",
    type: "object",
    properties: {
      name: { type: "string" },
      homeAddress: { $ref: "address.json" },
      workAddress: { $ref: "address.json" }
    }
  }
};

// Add schemas to AJV
ajv.addSchema(schemas.address);
ajv.addSchema(schemas.person);

const validatePerson = ajv.getSchema("https://example.com/schemas/person.json");
Enter fullscreen mode Exit fullscreen mode

Error Handling Like a Pro

Default AJV errors are... functional. But we can make them much better:

import addErrors from 'ajv-errors';
addErrors(ajv);

const betterUserSchema = {
  type: "object",
  properties: {
    username: {
      type: "string",
      minLength: 3,
      maxLength: 20,
      pattern: "^[a-zA-Z0-9_]+$"
    },
    password: {
      type: "string",
      minLength: 8,
      pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)"
    }
  },
  required: ["username", "password"],

  // Custom error messages
  errorMessage: {
    properties: {
      username: "Username must be 3-20 characters long and contain only letters, numbers, and underscores",
      password: "Password must be at least 8 characters with uppercase, lowercase, and a number"
    },
    required: {
      username: "Username is required",
      password: "Password is required"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Performance Tips That Actually Matter

1. Compile Once, Use Many Times

// ❌ DON'T do this
function validateUserData(data: any) {
  const validate = ajv.compile(userSchema); // Compiling every time!
  return validate(data);
}

// ✅ DO this instead
const validateUserData = ajv.compile(userSchema); // Compile once

function checkUser(data: any) {
  return validateUserData(data); // Reuse compiled function
}
Enter fullscreen mode Exit fullscreen mode

2. Use removeAdditional for Large Objects

const ajv = new Ajv({ 
  removeAdditional: "all" // Strip unknown properties automatically
});

// Input: { name: "Bob", age: 25, secretHackerData: "evil" }
// After validation: { name: "Bob", age: 25 }
Enter fullscreen mode Exit fullscreen mode

3. Optimize for Your Use Case

// For APIs - fast validation, detailed errors
const apiAjv = new Ajv({ 
  allErrors: true,
  verbose: true 
});

// For data processing - fastest validation
const processingAjv = new Ajv({ 
  allErrors: false,
  verbose: false,
  validateSchema: false
});
Enter fullscreen mode Exit fullscreen mode

TypeScript Integration (The Cherry on Top)

AJV plays beautifully with TypeScript:

import { JSONSchemaType } from 'ajv';

// Define your TypeScript interface
interface User {
  id: string;
  name: string;
  age: number;
  email?: string;
}

// Create a typed schema
const userSchema: JSONSchemaType<User> = {
  type: "object",
  properties: {
    id: { type: "string" },
    name: { type: "string" },
    age: { type: "number" },
    email: { type: "string", nullable: true }
  },
  required: ["id", "name", "age"],
  additionalProperties: false
};

// Compile with type safety
const validateUser = ajv.compile(userSchema);

// TypeScript knows the validated data structure!
function processUser(userData: unknown): User | null {
  if (validateUser(userData)) {
    // userData is now typed as User!
    return userData;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Common Gotchas (Learn From My Mistakes)

1. Format Validation Needs ajv-formats

// This won't work without ajv-formats
const schema = {
  type: "string",
  format: "email" // Silently ignored!
};

// Fix it
import addFormats from 'ajv-formats';
addFormats(ajv);
Enter fullscreen mode Exit fullscreen mode

2. RegExp Patterns Need Double Escaping

// ❌ Wrong
pattern: "^\d{3}-\d{3}-\d{4}$"

// ✅ Correct
pattern: "^\\d{3}-\\d{3}-\\d{4}$"
Enter fullscreen mode Exit fullscreen mode

3. additionalProperties vs Properties

// This allows ANY additional properties
const schema1 = {
  type: "object",
  properties: { name: { type: "string" } }
  // additionalProperties defaults to true
};

// This blocks additional properties
const schema2 = {
  type: "object",
  properties: { name: { type: "string" } },
  additionalProperties: false
};
Enter fullscreen mode Exit fullscreen mode

Wrapping Up Part 2

You now have the power to implement robust, fast, and maintainable validation in your applications! AJV transforms JSON Schema from a simple concept into a validation powerhouse.

Coming Up in Part 3...

In the final part of our series, we'll explore real-world applications:

  • Building API validation middleware
  • Data pipeline validation patterns
  • HAL (Hypermedia) schemas for self-describing APIs
  • Testing strategies for schema validation
  • Production ready tips

Ready to put this knowledge to work in production? Part 3 is where the magic happens! 🎯


Series Navigation:

Top comments (0)