Merge pull request #4006 from github/mbg/json/schema-checker

Add schema checker to `json` module
This commit is contained in:
Michael B. Gale
2026-07-13 15:28:24 +00:00
committed by GitHub
3 changed files with 383 additions and 49 deletions

128
lib/entry-points.js generated
View File

@@ -29647,13 +29647,13 @@ var require_helpers = __commonJS({
exports2.encodePath = function encodePointer(a) {
return a.map(pathEncoder).join("");
};
exports2.getDecimalPlaces = function getDecimalPlaces(number) {
exports2.getDecimalPlaces = function getDecimalPlaces(number2) {
var decimalPlaces = 0;
if (isNaN(number)) return decimalPlaces;
if (typeof number !== "number") {
number = Number(number);
if (isNaN(number2)) return decimalPlaces;
if (typeof number2 !== "number") {
number2 = Number(number2);
}
var parts = number.toString().split("e");
var parts = number2.toString().split("e");
if (parts.length === 2) {
if (parts[1][0] !== "-") {
return decimalPlaces;
@@ -78733,9 +78733,9 @@ var require_enum_object = __commonJS({
if (!isEnumObject(enumObject))
throw new Error("not a typescript enum object");
let values = [];
for (let [name, number] of Object.entries(enumObject))
if (typeof number == "number")
values.push({ name, number });
for (let [name, number2] of Object.entries(enumObject))
if (typeof number2 == "number")
values.push({ name, number: number2 });
return values;
}
exports2.listEnumValues = listEnumValues;
@@ -92751,10 +92751,10 @@ var require_util12 = __commonJS({
return arg == null;
}
exports2.isNullOrUndefined = isNullOrUndefined;
function isNumber(arg) {
function isNumber2(arg) {
return typeof arg === "number";
}
exports2.isNumber = isNumber;
exports2.isNumber = isNumber2;
function isString3(arg) {
return typeof arg === "string";
}
@@ -101524,24 +101524,24 @@ var require_operators = __commonJS({
}
}.call(this);
}
function toIntegerOrInfinity(number) {
number = Number2(number);
if (NumberIsNaN(number)) {
function toIntegerOrInfinity(number2) {
number2 = Number2(number2);
if (NumberIsNaN(number2)) {
return 0;
}
if (number < 0) {
throw new ERR_OUT_OF_RANGE("number", ">= 0", number);
if (number2 < 0) {
throw new ERR_OUT_OF_RANGE("number", ">= 0", number2);
}
return number;
return number2;
}
function drop(number, options = void 0) {
function drop(number2, options = void 0) {
if (options != null) {
validateObject(options, "options");
}
if ((options === null || options === void 0 ? void 0 : options.signal) != null) {
validateAbortSignal(options.signal, "options.signal");
}
number = toIntegerOrInfinity(number);
number2 = toIntegerOrInfinity(number2);
return async function* drop2() {
var _options$signal5;
if (options !== null && options !== void 0 && (_options$signal5 = options.signal) !== null && _options$signal5 !== void 0 && _options$signal5.aborted) {
@@ -101552,20 +101552,20 @@ var require_operators = __commonJS({
if (options !== null && options !== void 0 && (_options$signal6 = options.signal) !== null && _options$signal6 !== void 0 && _options$signal6.aborted) {
throw new AbortError();
}
if (number-- <= 0) {
if (number2-- <= 0) {
yield val;
}
}
}.call(this);
}
function take(number, options = void 0) {
function take(number2, options = void 0) {
if (options != null) {
validateObject(options, "options");
}
if ((options === null || options === void 0 ? void 0 : options.signal) != null) {
validateAbortSignal(options.signal, "options.signal");
}
number = toIntegerOrInfinity(number);
number2 = toIntegerOrInfinity(number2);
return async function* take2() {
var _options$signal7;
if (options !== null && options !== void 0 && (_options$signal7 = options.signal) !== null && _options$signal7 !== void 0 && _options$signal7.aborted) {
@@ -101576,10 +101576,10 @@ var require_operators = __commonJS({
if (options !== null && options !== void 0 && (_options$signal8 = options.signal) !== null && _options$signal8 !== void 0 && _options$signal8.aborted) {
throw new AbortError();
}
if (number-- > 0) {
if (number2-- > 0) {
yield val;
}
if (number <= 0) {
if (number2 <= 0) {
return;
}
}
@@ -124893,8 +124893,8 @@ var require_util16 = __commonJS({
parts.push(format.substring(last));
return parts.join("");
};
util3.formatNumber = function(number, decimals, dec_point, thousands_sep) {
var n = number, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals;
util3.formatNumber = function(number2, decimals, dec_point, thousands_sep) {
var n = number2, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals;
var d = dec_point === void 0 ? "," : dec_point;
var t = thousands_sep === void 0 ? "." : thousands_sep, s = n < 0 ? "-" : "";
var i = parseInt(n = Math.abs(+n || 0).toFixed(c), 10) + "";
@@ -144502,35 +144502,95 @@ function isArray(value) {
function isString(value) {
return typeof value === "string";
}
function isNumber(value) {
return typeof value === "number";
}
function isStringOrUndefined(value) {
return value === void 0 || isString(value);
}
var string = {
validate: isString,
required: true
};
function defaultCheck(validate) {
return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) });
}
function makeValidator(validate, required = true) {
return {
validate,
check: defaultCheck(validate),
required
};
}
var string = makeValidator(isString);
var number = makeValidator(isNumber);
function optionalOrNull(validator) {
return {
validate: (val) => {
return val === void 0 || val === null || validator.validate(val);
},
check: (val, opts, path29) => {
if (val === void 0 || val === null) {
return successfulCheckSchema();
}
return validator.check(val, opts, path29);
},
required: false
};
}
function validateSchema(schema, obj) {
const result = checkSchema(schema, obj, { failFast: true });
return result.valid;
}
function successfulCheckSchema() {
return {
valid: true,
unknownKeys: [],
invalidKeys: []
};
}
function checkSchema(schema, obj, options = {}, path29 = "") {
const result = successfulCheckSchema();
const inputKeys = new Set(Object.keys(obj));
const invalidKeys = /* @__PURE__ */ new Set();
for (const [key, validator] of Object.entries(schema)) {
const hasKey = key in obj;
inputKeys.delete(key);
invalidKeys.add(key);
if (validator.required && !hasKey) {
return false;
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
if (validator.required && (obj[key] === void 0 || obj[key] === null)) {
return false;
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
if (hasKey && !validator.validate(obj[key])) {
return false;
if (hasKey) {
const checkResult = validator.check(obj[key], options, `${path29}.${key}`);
result.unknownKeys.push(...checkResult.unknownKeys);
result.invalidKeys.push(...checkResult.invalidKeys);
if (checkResult.invalidKeys.length > 0) {
invalidKeys.delete(key);
}
if (!checkResult.valid) {
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
}
invalidKeys.delete(key);
}
return true;
for (const remainingKey of inputKeys) {
result.unknownKeys.push(`${path29}.${remainingKey}`);
}
for (const invalidKey of invalidKeys) {
result.invalidKeys.push(`${path29}.${invalidKey}`);
}
return result;
}
// src/util.ts

View File

@@ -67,3 +67,76 @@ test("validateSchema - optional properties are optional", async (t) => {
t.true(json.validateSchema(optionalSchema, { optionalKey: "" }));
t.true(json.validateSchema(optionalSchema, { optionalKey: "foo" }));
});
const arraySchema = {
arrayKey: json.array(json.number),
};
test("validateSchema - validates arrays", async (t) => {
// Arrays of numeric elements are accepted.
t.true(json.validateSchema(arraySchema, { arrayKey: [] }));
t.true(json.validateSchema(arraySchema, { arrayKey: [4] }));
t.true(json.validateSchema(arraySchema, { arrayKey: [4, 8] }));
t.true(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15] }));
// Other array elements are not accepted.
t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15, "bar"] }));
t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, undefined] }));
t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15, null] }));
});
const objectSchema = {
objectKey: json.object(arraySchema),
};
test("validateSchema - validates objects", async (t) => {
// Objects of the given schema are accepted.
t.true(json.validateSchema(objectSchema, { objectKey: { arrayKey: [] } }));
t.true(json.validateSchema(objectSchema, { objectKey: { arrayKey: [4] } }));
// Other values are not accepted.
t.false(json.validateSchema(objectSchema, {}));
t.false(json.validateSchema(objectSchema, { objectKey: [] }));
t.false(json.validateSchema(objectSchema, { objectKey: undefined }));
t.false(json.validateSchema(objectSchema, { objectKey: null }));
t.false(json.validateSchema(objectSchema, { objectKey: "foo" }));
t.false(json.validateSchema(objectSchema, { objectKey: 123 }));
});
const checkSchemaTestSchema = {
rootKey: json.object(objectSchema),
};
test("checkSchema - reports unknown keys", async (t) => {
const result = json.checkSchema(checkSchemaTestSchema, {
rootKey: {
objectKey: {
arrayKey: [],
},
nestedExtraKey: "foo",
},
extraKey: "bar",
});
t.true(result.valid);
t.deepEqual(
result.unknownKeys.sort(),
[".extraKey", ".rootKey.nestedExtraKey"].sort(),
);
});
test("checkSchema - reports invalid keys", async (t) => {
const result = json.checkSchema(checkSchemaTestSchema, {
rootKey: {
objectKey: {
arrayKey: ["foo"],
},
},
});
t.false(result.valid);
t.deepEqual(
result.invalidKeys.sort(),
[".rootKey.objectKey.arrayKey[0]"].sort(),
);
});

View File

@@ -48,23 +48,107 @@ export function isStringOrUndefined(
*/
export type Validator<T> = {
validate: (val: unknown) => val is T;
check: (
val: unknown,
opts: CheckSchemaOptions,
path: string,
) => CheckSchemaResult;
required: boolean;
};
function defaultCheck(
validate: (val: unknown) => val is any,
): (arg: unknown) => CheckSchemaResult {
return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) });
}
function makeValidator<T>(
validate: (arg: unknown) => arg is T,
required: boolean = true,
) {
return {
validate,
check: defaultCheck(validate),
required,
} as const satisfies Validator<T>;
}
/** Extracts `T` from `Validator<T>`. */
export type UnwrapValidator<V> = V extends Validator<infer A> ? A : never;
/** A validator for string fields in schemas. */
export const string = {
validate: isString,
required: true,
} as const satisfies Validator<string>;
export const string = makeValidator(isString);
/** A validator for number fields in schemas. */
export const number = {
validate: isNumber,
required: true,
} as const satisfies Validator<number>;
export const number = makeValidator(isNumber);
/** A validator for arrays. */
export function array<T>(validator: Validator<T>) {
const validate = (val: unknown) => {
return isArray(val) && val.every((e) => validator.validate(e));
};
return {
validate,
check: (val: unknown, opts: CheckSchemaOptions, path: string) => {
const result: CheckSchemaResult = successfulCheckSchema();
// The value must be an array.
if (!isArray(val)) {
result.valid = false;
return result;
}
// Validate all elements of the array.
let index = 0;
for (const e of val) {
const elementPath = `${path}[${index}]`;
const eResult = validator.check(e, opts, `${elementPath}`);
result.invalidKeys.push(...eResult.invalidKeys);
result.unknownKeys.push(...eResult.unknownKeys);
index++;
if (!eResult.valid) {
result.valid = false;
// Add the element path to `invalidKeys` if we didn't get
// any more specific ones from the element validator.
if (eResult.invalidKeys.length === 0) {
result.invalidKeys.push(elementPath);
}
if (opts.failFast) {
return result;
}
continue;
}
}
return result;
},
required: true,
} as const satisfies Validator<T[]>;
}
/** A validator for objects. */
export function object<
S extends Schema,
T extends UnvalidatedObject<any> = FromSchema<S>,
>(schema: S) {
return {
validate: (val: unknown) => {
return isObject(val) && validateSchema<S, T>(schema, val);
},
check: (val, opts, path) => {
if (!isObject(val)) {
return invalidCheckSchema();
}
return checkSchema(schema, val, opts, path);
},
required: true,
} as const satisfies Validator<T>;
}
/**
* Transforms a validator to be optional, accepting `undefined` or `null` for an
@@ -75,6 +159,12 @@ export function optionalOrNull<T>(validator: Validator<T>) {
validate: (val: unknown) => {
return val === undefined || val === null || validator.validate(val);
},
check: (val, opts, path) => {
if (val === undefined || val === null) {
return successfulCheckSchema();
}
return validator.check(val, opts, path);
},
required: false,
} as const satisfies Validator<T | undefined | null>;
}
@@ -88,6 +178,12 @@ export function optional<T>(validator: Validator<T>) {
validate: (val: unknown): val is T | undefined => {
return val === undefined || validator.validate(val);
},
check: (val, opts, path) => {
if (val === undefined) {
return successfulCheckSchema();
}
return validator.check(val, opts, path);
},
required: false,
} as const satisfies Validator<T | undefined>;
}
@@ -117,28 +213,133 @@ export type FromSchema<S extends Schema> = {
* @param obj The object to validate.
* @returns Asserts that `obj` is of the `schema`'s type if validation is successful.
*/
export function validateSchema<S extends Schema>(
export function validateSchema<
S extends Schema,
T extends UnvalidatedObject<any> = FromSchema<S>,
>(schema: S, obj: UnvalidatedObject<any>): obj is T {
const result = checkSchema(schema, obj, { failFast: true });
return result.valid;
}
export interface CheckSchemaOptions {
/** Whether to stop validation after the first error. */
failFast?: boolean;
}
export interface CheckSchemaResult {
/** Whether the `obj` satisfies the schema. */
valid: boolean;
/** Unknown keys that were found during validation. */
unknownKeys: string[];
/** Known keys that failed validation. */
invalidKeys: string[];
}
/**
* Convenience function to produce a `CheckSchemaResult` where `valid: true`.
*/
function successfulCheckSchema(): CheckSchemaResult {
return {
valid: true,
unknownKeys: [],
invalidKeys: [],
};
}
/**
* Convenience function to produce a `CheckSchemaResult` where `valid: false`.
*/
function invalidCheckSchema(): CheckSchemaResult {
return {
valid: false,
unknownKeys: [],
invalidKeys: [],
};
}
export function checkSchema<S extends Schema>(
schema: S,
obj: UnvalidatedObject<any>,
): obj is FromSchema<S> {
options: CheckSchemaOptions = {},
path: string = "",
): CheckSchemaResult {
const result: CheckSchemaResult = successfulCheckSchema();
// Track the set of input keys. We remove keys from this set as we recognise them
// during validation.
const inputKeys = new Set(Object.keys(obj));
// Track keys that have failed validation, starting with the empty set.
const invalidKeys = new Set();
// Loop through all keys in the object schema and validate that the given object
// satisfies the schema key.
for (const [key, validator] of Object.entries(schema)) {
const hasKey = key in obj;
// Remove key from set of unrecognised keys.
inputKeys.delete(key);
// Add the key to the set of invalid keys. We remove it later once
// it passes validation.
invalidKeys.add(key);
// If the property is required, but absent, fail.
if (validator.required && !hasKey) {
return false;
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
// If the property is required, but undefined or null, fail.
if (validator.required && (obj[key] === undefined || obj[key] === null)) {
return false;
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
// If the property is present, validate it.
if (hasKey && !validator.validate(obj[key])) {
return false;
if (hasKey) {
const checkResult = validator.check(obj[key], options, `${path}.${key}`);
result.unknownKeys.push(...checkResult.unknownKeys);
result.invalidKeys.push(...checkResult.invalidKeys);
// If we have invalid keys from the validator, then that means that
// we have a more specific key than `key`. Remove `key` from the results.
if (checkResult.invalidKeys.length > 0) {
invalidKeys.delete(key);
}
if (!checkResult.valid) {
result.valid = false;
if (options.failFast) {
break;
}
continue;
}
}
// If we reach this point, the key has been successfully validated.
invalidKeys.delete(key);
}
return true;
// If there are any remaining keys in `inputKeys`, add them to `unknownKeys`.
for (const remainingKey of inputKeys) {
result.unknownKeys.push(`${path}.${remainingKey}`);
}
// If there are any remaining keys in `invalidKeys`, add them to the result.
for (const invalidKey of invalidKeys) {
result.invalidKeys.push(`${path}.${invalidKey}`);
}
return result;
}