Skip to content

Strict Output Validator — JavaScript source

Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * Strict Output Validator — check a JSON Schema against OpenAI structured-
 * outputs strict-mode rules, so it fails here instead of at the API.
 * Port of src/lib/strictOutputValidator.ts
 *
 * Language: JavaScript (ES2022+, ES module; runs unmodified in Node 18+
 *           and modern browsers)
 * Source:   CosmoDev polyglot showcase port of the Strict Output Validator
 *           tool (slug: strict-output-validator), ported from
 *           src/lib/strictOutputValidator.ts (the canonical TypeScript
 *           implementation).
 * Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
 * License:  display source — part of CosmoDev's polyglot tool pages.
 *
 * Rules (2026 OpenAI strict mode):
 *   R1 root must be type "object"            (validateStrictRoot)
 *   R2 every object node needs additionalProperties: false
 *   R3 every key in properties must be listed in required (no optional keys)
 *   R4 required must not name keys absent from properties
 *   R5 only the supported type values / keywords may appear
 * The keyword allowlist is conservative: keywords OpenAI documents as
 * unsupported are flagged so the verdict is actionable, not just binary.
 */

/**
 * A single rule violation.
 * @typedef {'root-not-object'|'missing-additional-properties'|'property-not-required'|'required-not-property'|'unsupported-type'|'unsupported-keyword'|'invalid-schema'} StrictRule
 */

/**
 * @typedef {{path: string, rule: StrictRule, message: string}} StrictIssue
 */

/**
 * @typedef {{ok: boolean, issues: StrictIssue[], counts: {objects: number, properties: number, enums: number}}} StrictReport
 */

/** Types strict mode supports. */
export const SUPPORTED_TYPES = new Set([
  'object',
  'array',
  'string',
  'number',
  'integer',
  'boolean',
]);

/** Keywords strict mode understands per-node. Everything else is flagged. */
export const SUPPORTED_KEYWORDS = new Set([
  'type',
  'description',
  'title',
  'properties',
  'required',
  'additionalProperties',
  'items',
  'enum',
  'const',
  'anyOf',
  'allOf', // accepted only as single-element; checked in the walker
  '$ref',
  '$defs',
  'definitions',
  'format',
  'nullable',
  'default',
]);

/** True for plain objects (not null, not arrays). */
function isObj(v) {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
 * Validate a schema against the strict-mode structural rules. Accepts either
 * a pre-parsed schema object or a JSON string (parsed here; a parse failure
 * is reported as a single `invalid-schema` issue). R1 (root must be an
 * object) is NOT checked here — use {@link validateStrictRoot} for that.
 * @param {unknown} input
 * @returns {StrictReport}
 */
export function validateStrictSchema(input) {
  const issues = [];
  const counts = { objects: 0, properties: 0, enums: 0 };

  let schema = input;
  if (typeof input === 'string') {
    try {
      schema = JSON.parse(input);
    } catch (e) {
      return {
        ok: false,
        issues: [
          {
            path: '$',
            rule: 'invalid-schema',
            message: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
          },
        ],
        counts,
      };
    }
  }
  if (!isObj(schema)) {
    return {
      ok: false,
      issues: [{ path: '$', rule: 'invalid-schema', message: 'Schema must be a JSON object.' }],
      counts,
    };
  }

  walk(schema, '$');
  return { ok: issues.length === 0, issues, counts };

  /** Flag every keyword strict mode does not understand. */
  function unsupportedKeywords(node, path) {
    for (const key of Object.keys(node)) {
      if (!SUPPORTED_KEYWORDS.has(key)) {
        issues.push({
          path,
          rule: 'unsupported-keyword',
          message: `"${key}" is not supported in strict mode — remove it or express the constraint another way.`,
        });
      }
    }
  }

  /** Depth-first walk emitting issues and accumulating counts. */
  function walk(node, path) {
    unsupportedKeywords(node, path);

    const type = node.type;
    // 'null' is only expressible inside a type array (the nullable form).
    const isNullableForm = Array.isArray(type);
    const typeList = isNullableForm ? type : typeof type === 'string' ? [type] : [];
    for (const t of typeList) {
      const supported =
        typeof t === 'string' && (SUPPORTED_TYPES.has(t) || (isNullableForm && t === 'null'));
      if (!supported) {
        issues.push({
          path,
          rule: 'unsupported-type',
          message: `type ${JSON.stringify(t)} is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).`,
        });
      }
    }

    // allOf is accepted only as a single-element wrapper.
    if (Array.isArray(node.allOf) && node.allOf.length !== 1) {
      issues.push({
        path,
        rule: 'unsupported-keyword',
        message: 'allOf is supported only with exactly one subschema (use anyOf for unions).',
      });
    }

    if (node.type === 'object' || node.properties !== undefined || node.required !== undefined) {
      counts.objects += 1;
      if (node.additionalProperties !== false) {
        issues.push({
          path,
          rule: 'missing-additional-properties',
          message: 'Object needs "additionalProperties": false — strict mode rejects open objects.',
        });
      }
      const props = isObj(node.properties) ? node.properties : {};
      const required = Array.isArray(node.required) ? node.required : [];
      counts.properties += Object.keys(props).length;
      for (const key of Object.keys(props)) {
        if (!required.includes(key)) {
          issues.push({
            path: `${path}.required`,
            rule: 'property-not-required',
            message: `"${key}" is defined in properties but missing from required — strict mode requires every property.`,
          });
        }
      }
      for (const key of required) {
        if (typeof key === 'string' && !(key in props)) {
          issues.push({
            path: `${path}.required`,
            rule: 'required-not-property',
            message: `"${key}" is required but has no definition in properties.`,
          });
        }
      }
      for (const [key, sub] of Object.entries(props)) {
        if (isObj(sub)) walk(sub, `${path}.properties.${key}`);
      }
    }

    if (isObj(node.items)) {
      walk(node.items, `${path}.items`);
    }
    if (Array.isArray(node.enum)) counts.enums += 1;
    for (const listKey of ['anyOf', 'oneOf', 'allOf']) {
      const list = node[listKey];
      if (Array.isArray(list)) {
        if (listKey === 'oneOf') {
          issues.push({
            path: `${path}.${listKey}`,
            rule: 'unsupported-keyword',
            message: 'oneOf is not supported — strict mode unions are expressed with anyOf.',
          });
        }
        list.forEach((sub, i) => {
          if (isObj(sub)) walk(sub, `${path}.${listKey}[${i}]`);
        });
      }
    }
    for (const defsKey of ['$defs', 'definitions']) {
      const defs = node[defsKey];
      if (isObj(defs)) {
        for (const [name, sub] of Object.entries(defs)) {
          if (isObj(sub)) walk(sub, `${path}.${defsKey}.${name}`);
        }
      }
    }
  }
}

/**
 * Whole-report entry point: everything {@link validateStrictSchema} checks,
 * plus R1 — the root schema must be type "object" (strict mode cannot return
 * a bare scalar or array). The root issue is unshifted to the front.
 * @param {unknown} input
 * @returns {StrictReport}
 */
export function validateStrictRoot(input) {
  const report = validateStrictSchema(input);
  let schema = input;
  if (typeof input === 'string') {
    try {
      schema = JSON.parse(input);
    } catch {
      return report; // invalid-schema already reported
    }
  }
  if (isObj(schema) && schema.type !== 'object') {
    report.issues.unshift({
      path: '$',
      rule: 'root-not-object',
      message:
        'The root schema must be type "object" — strict mode cannot return a bare scalar or array.',
    });
    report.ok = false;
  }
  return report;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →