{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreicb5cslf442zoawrl6dkuqjbt5g6go4tch73hwezawjtwkyqcm2li",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpdbtzhr56u2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiekim4xwt7ktwnohkfgpex5xgvqf7h6vxqo6xg77iitlqaalzs2si"
    },
    "mimeType": "image/webp",
    "size": 87984
  },
  "path": "/dev_nestio_229945f10652e4/i-built-a-browser-only-json-schema-validator-draft-07-ref-allofanyofoneof-ifthenelse-173-3pnm",
  "publishedAt": "2026-06-28T05:20:16.000Z",
  "site": "https://dev.to",
  "tags": [
    "webdev",
    "javascript",
    "json",
    "tooling",
    "json-schema-validator-dev.pages.dev",
    "devnestio.pages.dev",
    "@typedef"
  ],
  "textContent": "#  I Built a Browser-Only JSON Schema Validator — Draft-07, $ref, allOf/anyOf/oneOf, if/then/else, 173 Tests\n\nJSON Schema validation usually means pulling in `ajv` or a similar library. That's entirely reasonable for production code — but for quick schema checking, debugging, or learning how Draft-07 keywords work, you don't want to spin up a Node project just to paste in some JSON.\n\nSo I built a zero-dependency browser tool that implements the Draft-07 spec from scratch.\n\n**Live tool → json-schema-validator-dev.pages.dev**\n\n**All tools → devnestio.pages.dev**\n\n##  What it validates\n\nThe tool implements a solid subset of JSON Schema Draft-07:\n\nKeyword group | Keywords\n---|---\nType system |  `type` (string, number, integer, boolean, array, object, null, arrays of types)\nEnumeration |  `enum`, `const`\nString |  `minLength`, `maxLength`, `pattern`, `format`\nNumber |  `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`\nArray |  `items` (schema + tuple), `additionalItems`, `minItems`, `maxItems`, `uniqueItems`, `contains`\nObject |  `required`, `properties`, `additionalProperties`, `patternProperties`, `minProperties`, `maxProperties`, `dependencies`, `propertyNames`\nCombining |  `allOf`, `anyOf`, `oneOf`, `not`\nConditionals |  `if` / `then` / `else`\nReferences |  `$ref` (local: `#/$defs/...`, `#/definitions/...`)\nFormats |  `email`, `uri`, `date`, `date-time`, `ipv4`, `uuid`\n\nEvery error includes:\n\n  * **JSON path** to the failing value (e.g. `user.address.zipCode`)\n  * **Human-readable message** (e.g. `String does not match pattern \"^\\d{5}$\"`)\n  * **Keyword badge** so you know exactly which schema rule failed\n\n\n\n##  Architecture: pure recursive validator\n\nThe entire validator is a single recursive function `validate(data, schema, path, rootSchema)`. It takes a value, a schema object, the current path string, and the root schema (for `$ref` resolution), and returns an array of `ValidationError` objects.\n\n\n\n    /**\n     * @typedef {{ path: string, message: string, keyword: string }} ValidationError\n     */\n\n    function validate(data, schema, path, rootSchema) {\n      path = path || '';\n      rootSchema = rootSchema || schema;\n\n      if (schema === true) return [];\n      if (schema === false) return [{ path, message: 'Schema is false', keyword: 'false schema' }];\n\n      const errors = [];\n\n      // $ref short-circuits all other keywords (Draft-07 spec)\n      if (schema.$ref) {\n        const resolved = resolveRef(schema.$ref, rootSchema);\n        if (resolved) return validate(data, resolved, path, rootSchema);\n        return [{ path, message: `Cannot resolve $ref \"${schema.$ref}\"`, keyword: '$ref' }];\n      }\n\n      // Type check first — no point checking string keywords on a number\n      if (schema.type !== undefined && !typeMatches(data, schema.type)) {\n        const expected = Array.isArray(schema.type) ? schema.type.join(' | ') : schema.type;\n        return [{ path, message: `Expected \"${expected}\" but got \"${getType(data)}\"`, keyword: 'type' }];\n      }\n\n      if (typeof data === 'string') errors.push(...validateString(data, schema, path));\n      if (typeof data === 'number') errors.push(...validateNumber(data, schema, path));\n      if (Array.isArray(data))     errors.push(...validateArray(data, schema, path, rootSchema));\n      if (isPlainObject(data))     errors.push(...validateObject(data, schema, path, rootSchema));\n\n      // Combiners\n      if (schema.allOf) schema.allOf.forEach(s => errors.push(...validate(data, s, path, rootSchema)));\n      if (schema.anyOf && !schema.anyOf.some(s => validate(data, s, path, rootSchema).length === 0))\n        errors.push({ path, message: 'Does not match any schema in \"anyOf\"', keyword: 'anyOf' });\n      // ...oneOf, not, if/then/else similarly\n\n      return errors;\n    }\n\n\nThe early return on type mismatch is important: there's no value in reporting `\"minLength\" requires 5 characters` when the value isn't even a string.\n\n##  Handling exclusiveMinimum/Maximum across drafts\n\nDraft-04 uses boolean `exclusiveMinimum` / `exclusiveMaximum` paired with `minimum` / `maximum`. Draft-06/07 changed them to be standalone numbers. The validator handles both:\n\n\n\n    function validateNumber(val, schema, path) {\n      const errors = [];\n\n      if (typeof schema.minimum === 'number') {\n        if (schema.exclusiveMinimum === true) {\n          // Draft-04 style\n          if (val <= schema.minimum)\n            errors.push({ path, message: `${val} must be > ${schema.minimum}`, keyword: 'exclusiveMinimum' });\n        } else {\n          if (val < schema.minimum)\n            errors.push({ path, message: `${val} must be >= ${schema.minimum}`, keyword: 'minimum' });\n        }\n      }\n\n      // Draft-06/07 style: exclusiveMinimum as a number\n      if (typeof schema.exclusiveMinimum === 'number') {\n        if (val <= schema.exclusiveMinimum)\n          errors.push({ path, message: `${val} must be > ${schema.exclusiveMinimum}`, keyword: 'exclusiveMinimum' });\n      }\n\n      // ... same pattern for maximum / exclusiveMaximum\n\n      return errors;\n    }\n\n\nThis means schemas from older tooling still work correctly.\n\n##  $ref resolution\n\nLocal `$ref` values like `#/$defs/Address` or `#/definitions/UUID` are resolved by walking the JSON pointer path from the root schema:\n\n\n\n    function resolveRef(ref, rootSchema) {\n      if (!ref.startsWith('#')) return null;  // external refs not supported\n      const fragment = ref.slice(1);\n      if (fragment === '' || fragment === '/') return rootSchema;  // bare # → root\n      const parts = fragment.slice(1)\n        .split('/')\n        .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));  // JSON Pointer unescaping\n      let cur = rootSchema;\n      for (const part of parts) {\n        if (cur === null || typeof cur !== 'object') return null;\n        cur = cur[part];\n      }\n      return cur !== undefined ? cur : null;\n    }\n\n\nPer the Draft-07 spec, when a schema has a `$ref`, all sibling keywords are ignored — `$ref` short-circuits everything else.\n\n##  if/then/else\n\nOne of the more useful Draft-07 additions is conditional validation:\n\n\n\n    if (schema.if !== undefined) {\n      const ifErrors = validate(data, schema.if, path, rootSchema);\n      if (ifErrors.length === 0) {\n        // condition passed → apply then\n        if (schema.then !== undefined)\n          errors.push(...validate(data, schema.then, path, rootSchema));\n      } else {\n        // condition failed → apply else\n        if (schema.else !== undefined)\n          errors.push(...validate(data, schema.else, path, rootSchema));\n      }\n    }\n\n\nThis lets you write schemas like \"if the `type` field is `'credit_card'`, then `cardNumber` is required\" without splitting into separate schemas.\n\n##  Testing: 173 tests, zero frameworks\n\nThe same vm-based extraction technique I use across all DevNest.io tools:\n\n\n\n    const vm = require('vm');\n    const modObj = { exports: {} };\n    vm.runInContext(extractedScript, vm.createContext({\n      module: modObj, exports: modObj.exports,\n      window: { addEventListener: () => {} },\n      document: { getElementById: () => ({}), querySelectorAll: () => [], addEventListener: () => {} },\n      Date, JSON, RegExp, Number, Math, Array, Object, String, Boolean, console\n    }));\n\n    const { validate, getType, typeMatches, isValidEmail, ... } = modObj.exports;\n\n\nTests cover:\n\nFunction | Tests\n---|---\n`getType` | 10 — null, boolean, integer vs number, string, array, object\n`typeMatches` | 15 — single types, type arrays, integer/number coercion\n`isValidEmail` | 10 — valid addresses, missing parts, spaces, double `@`\n`isValidUri` | 8 — schemes, missing scheme, bad scheme start\n`isValidDate` | 10 — valid dates, leap years, out-of-range months/days\n`isValidDateTime` | 8 — ISO 8601, offsets, milliseconds, invalid formats\n`isValidIPv4` | 8 — valid IPs, out-of-range octets, wrong part count\n`isValidUUID` | 8 — v1/v4, wrong variant, no hyphens\n`validateString` | 15 — minLength, maxLength, pattern, all formats\n`validateNumber` | 15 — min/max, exclusive variants (bool + number), multipleOf\n`validateArray` | 12 — minItems, maxItems, uniqueItems, items schema, tuple, additionalItems, contains\n`validateObject` | 15 — required, properties, additionalProperties, minProperties, maxProperties, patternProperties, dependencies\n`resolveRef` | 8 — `$defs`, `definitions`, `#` root, `~1` escaping\n`validate` (integration) | 30 — all major keywords end-to-end\n\nAll 173 pass with `node test/test.js`.\n\n##  UI design decisions\n\n**Split pane layout** : JSON on the left, Schema on the right, results spanning the full width below. Ctrl+Enter triggers validation from either pane.\n\n**Parse errors are shown per-pane** : if your JSON is malformed, you see the parse error under the JSON pane immediately — before even clicking Validate.\n\n**Error cards show three pieces of information** : the JSON path (in monospace, highlighted), the human message, and the failing keyword as a chip. This mirrors how `ajv` formats errors, making it easier to cross-reference against the spec.\n\n**Three built-in examples** : User (a well-formed object), Product (nested object + pattern properties), and Invalid (the User schema with deliberate violations) — so you can explore the tool immediately without having to write a schema from scratch.\n\n##  Try it\n\n**json-schema-validator-dev.pages.dev** — free, no login, no tracking.\n\nPart of **devnestio.pages.dev** — a growing collection of browser-only developer tools. Currently 27 tools and counting.",
  "title": "I Built a Browser-Only JSON Schema Validator — Draft-07, $ref, allOf/anyOf/oneOf, if/then/else, 173 Tests"
}