Minimal JSON Schema Validator

A dependency free validator for the common JSON Schema subset that reports every violation with its JSON path instead of stopping at the first mismatch.

TypescriptData Integrity

Code

export interface ValidationIssue {
  readonly path: string;
  readonly message: string;
}

export type ValidationResult =
  | { readonly valid: true }
  | { readonly valid: false; readonly issues: ValidationIssue[] };

type Schema = {
  readonly type?: "object" | "array" | "string" | "number" | "boolean" | "null";
  readonly required?: readonly string[];
  readonly properties?: Record<string, Schema>;
  readonly items?: Schema;
  readonly minLength?: number;
  readonly minimum?: number;
  readonly enum?: readonly unknown[];
};

function typeOf(value: unknown): string {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

function childPath(path: string, segment: string | number): string {
  // 数组下标用方括号,属性用点号——贴近常见 JSON path 书写习惯
  if (typeof segment === "number") return path + "[" + segment + "]";
  return path === "$" ? "$." + segment : path + "." + segment;
}

export function validate(value: unknown, schema: Schema, path = "$"): ValidationResult {
  const issues: ValidationIssue[] = [];
  walk(value, schema, path, issues);
  return issues.length === 0 ? { valid: true } : { valid: false, issues };
}

function walk(value: unknown, schema: Schema, path: string, issues: ValidationIssue[]): void {
  if (schema.type && typeOf(value) !== schema.type) {
    issues.push({
      path,
      message: "expected " + schema.type + " but got " + typeOf(value),
    });
    return;
  }

  if (schema.enum && !schema.enum.includes(value)) {
    issues.push({ path, message: "value must be one of " + JSON.stringify(schema.enum) });
  }

  if (schema.type === "string" && typeof value === "string") {
    if (schema.minLength !== undefined && value.length < schema.minLength) {
      issues.push({ path, message: "string shorter than " + schema.minLength + " characters" });
    }
  }

  if (schema.type === "number" && typeof value === "number") {
    if (schema.minimum !== undefined && value < schema.minimum) {
      issues.push({ path, message: "number below minimum " + schema.minimum });
    }
  }

  if (schema.type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
    const record = value as Record<string, unknown>;
    for (const key of schema.required ?? []) {
      if (!(key in record)) {
        issues.push({ path, message: "missing required property " + key });
      }
    }
    for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
      if (key in record) walk(record[key], childSchema, childPath(path, key), issues);
    }
  }

  if (schema.type === "array" && Array.isArray(value)) {
    if (schema.items) {
      value.forEach((item, index) => walk(item, schema.items as Schema, childPath(path, index), issues));
    }
  }
}