From fb5048ee886585db7ef2210fe32b077efa9a74a7 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 9 Jul 2026 23:27:42 +0100 Subject: [PATCH 01/12] Add `checkSchema` function --- lib/entry-points.js | 30 +++++++++++++++++++--- src/json/index.test.ts | 10 ++++++++ src/json/index.ts | 56 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index ee64cb6a5e..5bda904e48 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144518,19 +144518,41 @@ function optionalOrNull(validator) { }; } function validateSchema(schema, obj) { + const result = checkSchema(schema, obj, { failFast: true }); + return result.valid; +} +function checkSchema(schema, obj, options = {}, path29 = "") { + const result = { valid: true, unknownKeys: [] }; + const inputKeys = new Set(Object.keys(obj)); for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; if (validator.required && !hasKey) { - return false; + result.valid = false; + if (options.failFast) { + return result; + } + continue; } if (validator.required && (obj[key] === void 0 || obj[key] === null)) { - return false; + result.valid = false; + if (options.failFast) { + return result; + } + continue; } if (hasKey && !validator.validate(obj[key])) { - return false; + result.valid = false; + if (options.failFast) { + return result; + } + continue; } + inputKeys.delete(key); } - return true; + for (const remainingKey of inputKeys) { + result.unknownKeys.push(`${path29}${remainingKey}`); + } + return result; } // src/util.ts diff --git a/src/json/index.test.ts b/src/json/index.test.ts index eb259c757e..5234872e5a 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -67,3 +67,13 @@ test("validateSchema - optional properties are optional", async (t) => { t.true(json.validateSchema(optionalSchema, { optionalKey: "" })); t.true(json.validateSchema(optionalSchema, { optionalKey: "foo" })); }); + +test("validateSchema - checkSchema reports unknown keys", async (t) => { + const result = json.checkSchema(testSchema, { + requiredKey: "foo", + extraKey: "bar", + }); + + t.true(result.valid); + t.deepEqual(result.unknownKeys, ["extraKey"]); +}); diff --git a/src/json/index.ts b/src/json/index.ts index a55de5b589..d0a8edb519 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -121,24 +121,72 @@ export function validateSchema( schema: S, obj: UnvalidatedObject, ): obj is FromSchema { + 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[]; +} + +export function checkSchema( + schema: S, + obj: UnvalidatedObject, + options: CheckSchemaOptions = {}, + path: string = "", +): CheckSchemaResult { + const result: CheckSchemaResult = { valid: true, unknownKeys: [] }; + const inputKeys = new Set(Object.keys(obj)); + for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; // If the property is required, but absent, fail. if (validator.required && !hasKey) { - return false; + result.valid = false; + + if (options.failFast) { + return result; + } + 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) { + return result; + } + continue; } // If the property is present, validate it. if (hasKey && !validator.validate(obj[key])) { - return false; + result.valid = false; + + if (options.failFast) { + return result; + } + continue; } + + // If we reach this point, the key has been successfully validated. + inputKeys.delete(key); + } + + // If there are any remaining keys in `inputKeys`, add them to `unknownKeys`. + for (const remainingKey of inputKeys) { + result.unknownKeys.push(`${path}${remainingKey}`); } - return true; + return result; } From 6a62e99aa7aa907eadba5fc98937bd786ee1fa79 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 9 Jul 2026 23:36:50 +0100 Subject: [PATCH 02/12] Add an `array` `Validator` --- src/json/index.test.ts | 17 +++++++++++++++++ src/json/index.ts | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/json/index.test.ts b/src/json/index.test.ts index 5234872e5a..a238d80716 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -68,6 +68,23 @@ test("validateSchema - optional properties are optional", async (t) => { 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] })); +}); + test("validateSchema - checkSchema reports unknown keys", async (t) => { const result = json.checkSchema(testSchema, { requiredKey: "foo", diff --git a/src/json/index.ts b/src/json/index.ts index d0a8edb519..e9fbbd8a71 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -66,6 +66,16 @@ export const number = { required: true, } as const satisfies Validator; +/** A validator for arrays. */ +export function array(validator: Validator) { + return { + validate: (val: unknown) => { + return isArray(val) && val.every((e) => validator.validate(e)); + }, + required: true, + } as const satisfies Validator; +} + /** * Transforms a validator to be optional, accepting `undefined` or `null` for an * absent value. From 50d76e2fa3a057fbae009851a89d7a4b22309ee9 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 9 Jul 2026 23:42:28 +0100 Subject: [PATCH 03/12] Add an `object` `Validator` --- src/json/index.test.ts | 18 ++++++++++++++++++ src/json/index.ts | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/json/index.test.ts b/src/json/index.test.ts index a238d80716..71f8000031 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -85,6 +85,24 @@ test("validateSchema - validates arrays", async (t) => { 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 })); +}); + test("validateSchema - checkSchema reports unknown keys", async (t) => { const result = json.checkSchema(testSchema, { requiredKey: "foo", diff --git a/src/json/index.ts b/src/json/index.ts index e9fbbd8a71..083bd49bbe 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -76,6 +76,16 @@ export function array(validator: Validator) { } as const satisfies Validator; } +/** A validator for objects. */ +export function object(schema: Schema) { + return { + validate: (val: unknown) => { + return isObject(val) && validateSchema(schema, val); + }, + required: true, + } as const satisfies Validator>; +} + /** * Transforms a validator to be optional, accepting `undefined` or `null` for an * absent value. From 36ef8893297f149d1c086c48694c50bdb31b21dc Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 00:01:13 +0100 Subject: [PATCH 04/12] Improve type inference for `object` and `validateSchema` --- src/json/index.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/json/index.ts b/src/json/index.ts index 083bd49bbe..325689d33e 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -77,13 +77,16 @@ export function array(validator: Validator) { } /** A validator for objects. */ -export function object(schema: Schema) { +export function object< + S extends Schema, + T extends UnvalidatedObject = FromSchema, +>(schema: S) { return { validate: (val: unknown) => { - return isObject(val) && validateSchema(schema, val); + return isObject(val) && validateSchema(schema, val); }, required: true, - } as const satisfies Validator>; + } as const satisfies Validator; } /** @@ -137,10 +140,10 @@ export type FromSchema = { * @param obj The object to validate. * @returns Asserts that `obj` is of the `schema`'s type if validation is successful. */ -export function validateSchema( - schema: S, - obj: UnvalidatedObject, -): obj is FromSchema { +export function validateSchema< + S extends Schema, + T extends UnvalidatedObject = FromSchema, +>(schema: S, obj: UnvalidatedObject): obj is T { const result = checkSchema(schema, obj, { failFast: true }); return result.valid; } From ec0b8ae45feb17387add946b85d30fb71f1d489b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 00:43:05 +0100 Subject: [PATCH 05/12] Allow nested checking --- lib/entry-points.js | 89 +++++++++++++++++++++++++---------------- src/json/index.test.ts | 18 +++++++-- src/json/index.ts | 90 +++++++++++++++++++++++++++++++++++------- 3 files changed, 145 insertions(+), 52 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 5bda904e48..f8de6f868e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -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,18 +144502,35 @@ 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: [], 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, path29) => { + if (val === void 0 || val === null) { + return { valid: true, unknownKeys: [] }; + } + return validator.check(val, path29); + }, required: false }; } @@ -144540,12 +144557,16 @@ function checkSchema(schema, obj, options = {}, path29 = "") { } continue; } - if (hasKey && !validator.validate(obj[key])) { - result.valid = false; - if (options.failFast) { - return result; + if (hasKey) { + const checkResult = validator.check(obj[key], `${path29}${key}.`); + result.unknownKeys.push(...checkResult.unknownKeys); + if (!checkResult.valid) { + result.valid = false; + if (options.failFast) { + return result; + } + continue; } - continue; } inputKeys.delete(key); } diff --git a/src/json/index.test.ts b/src/json/index.test.ts index 71f8000031..47956c9387 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -103,12 +103,24 @@ test("validateSchema - validates objects", async (t) => { t.false(json.validateSchema(objectSchema, { objectKey: 123 })); }); +const checkSchemaTestSchema = { + rootKey: json.object(objectSchema), +}; + test("validateSchema - checkSchema reports unknown keys", async (t) => { - const result = json.checkSchema(testSchema, { - requiredKey: "foo", + const result = json.checkSchema(checkSchemaTestSchema, { + rootKey: { + objectKey: { + arrayKey: [], + }, + nestedExtraKey: "foo", + }, extraKey: "bar", }); t.true(result.valid); - t.deepEqual(result.unknownKeys, ["extraKey"]); + t.deepEqual( + result.unknownKeys.sort(), + ["extraKey", "rootKey.nestedExtraKey"].sort(), + ); }); diff --git a/src/json/index.ts b/src/json/index.ts index 325689d33e..032952ad58 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -48,29 +48,65 @@ export function isStringOrUndefined( */ export type Validator = { validate: (val: unknown) => val is T; + check: (val: unknown, path: string) => CheckSchemaResult; required: boolean; }; +function defaultCheck( + validate: (val: unknown) => val is any, +): (arg: unknown) => CheckSchemaResult { + return (arg) => ({ unknownKeys: [], valid: validate(arg) }); +} + +function makeValidator( + validate: (arg: unknown) => arg is T, + required: boolean = true, +) { + return { + validate, + check: defaultCheck(validate), + required, + } as const satisfies Validator; +} + /** Extracts `T` from `Validator`. */ export type UnwrapValidator = V extends Validator ? A : never; /** A validator for string fields in schemas. */ -export const string = { - validate: isString, - required: true, -} as const satisfies Validator; +export const string = makeValidator(isString); /** A validator for number fields in schemas. */ -export const number = { - validate: isNumber, - required: true, -} as const satisfies Validator; +export const number = makeValidator(isNumber); /** A validator for arrays. */ export function array(validator: Validator) { + const validate = (val: unknown) => { + return isArray(val) && val.every((e) => validator.validate(e)); + }; return { - validate: (val: unknown) => { - return isArray(val) && val.every((e) => validator.validate(e)); + validate, + check: (val: unknown, path: string) => { + const result: CheckSchemaResult = { valid: true, unknownKeys: [] }; + + if (!isArray(val)) { + result.valid = false; + return result; + } + + let index = 0; + for (const e of val) { + const eResult = validator.check(e, `${path}[${index}].`); + + result.unknownKeys.push(...eResult.unknownKeys); + index++; + + if (!eResult.valid) { + result.valid = false; + continue; + } + } + + return result; }, required: true, } as const satisfies Validator; @@ -85,6 +121,12 @@ export function object< validate: (val: unknown) => { return isObject(val) && validateSchema(schema, val); }, + check: (val, path) => { + if (!isObject(val)) { + return { valid: false, unknownKeys: [] }; + } + return checkSchema(schema, val, {}, path); + }, required: true, } as const satisfies Validator; } @@ -98,6 +140,12 @@ export function optionalOrNull(validator: Validator) { validate: (val: unknown) => { return val === undefined || val === null || validator.validate(val); }, + check: (val, path) => { + if (val === undefined || val === null) { + return { valid: true, unknownKeys: [] }; + } + return validator.check(val, path); + }, required: false, } as const satisfies Validator; } @@ -111,6 +159,12 @@ export function optional(validator: Validator) { validate: (val: unknown): val is T | undefined => { return val === undefined || validator.validate(val); }, + check: (val, path) => { + if (val === undefined) { + return { valid: true, unknownKeys: [] }; + } + return validator.check(val, path); + }, required: false, } as const satisfies Validator; } @@ -193,13 +247,19 @@ export function checkSchema( } // If the property is present, validate it. - if (hasKey && !validator.validate(obj[key])) { - result.valid = false; + if (hasKey) { + const checkResult = validator.check(obj[key], `${path}${key}.`); - if (options.failFast) { - return result; + result.unknownKeys.push(...checkResult.unknownKeys); + + if (!checkResult.valid) { + result.valid = false; + + if (options.failFast) { + return result; + } + continue; } - continue; } // If we reach this point, the key has been successfully validated. From f8ed33e0674f37a4ad1f68fa2b0c2d7496695c92 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 13:10:25 +0100 Subject: [PATCH 06/12] Remove keys from unrecognised set as soon as found --- lib/entry-points.js | 2 +- src/json/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index f8de6f868e..191e25b239 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144543,6 +144543,7 @@ function checkSchema(schema, obj, options = {}, path29 = "") { const inputKeys = new Set(Object.keys(obj)); for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; + inputKeys.delete(key); if (validator.required && !hasKey) { result.valid = false; if (options.failFast) { @@ -144568,7 +144569,6 @@ function checkSchema(schema, obj, options = {}, path29 = "") { continue; } } - inputKeys.delete(key); } for (const remainingKey of inputKeys) { result.unknownKeys.push(`${path29}${remainingKey}`); diff --git a/src/json/index.ts b/src/json/index.ts index 032952ad58..b5c9650989 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -226,6 +226,9 @@ export function checkSchema( for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; + // Remove key from set of unrecognised keys. + inputKeys.delete(key); + // If the property is required, but absent, fail. if (validator.required && !hasKey) { result.valid = false; @@ -263,7 +266,6 @@ export function checkSchema( } // If we reach this point, the key has been successfully validated. - inputKeys.delete(key); } // If there are any remaining keys in `inputKeys`, add them to `unknownKeys`. From aec5441507d8875bdf068af4b4d8a25728f8ac06 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 13:18:45 +0100 Subject: [PATCH 07/12] Add convenience functions to produce `CheckSchemaResult` values --- lib/entry-points.js | 10 ++++++++-- src/json/index.ts | 30 +++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 191e25b239..e8b673085b 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144527,7 +144527,7 @@ function optionalOrNull(validator) { }, check: (val, path29) => { if (val === void 0 || val === null) { - return { valid: true, unknownKeys: [] }; + return successfulCheckSchema(); } return validator.check(val, path29); }, @@ -144538,8 +144538,14 @@ function validateSchema(schema, obj) { const result = checkSchema(schema, obj, { failFast: true }); return result.valid; } +function successfulCheckSchema() { + return { + valid: true, + unknownKeys: [] + }; +} function checkSchema(schema, obj, options = {}, path29 = "") { - const result = { valid: true, unknownKeys: [] }; + const result = successfulCheckSchema(); const inputKeys = new Set(Object.keys(obj)); for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; diff --git a/src/json/index.ts b/src/json/index.ts index b5c9650989..7bc23271e5 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -86,7 +86,7 @@ export function array(validator: Validator) { return { validate, check: (val: unknown, path: string) => { - const result: CheckSchemaResult = { valid: true, unknownKeys: [] }; + const result: CheckSchemaResult = successfulCheckSchema(); if (!isArray(val)) { result.valid = false; @@ -123,7 +123,7 @@ export function object< }, check: (val, path) => { if (!isObject(val)) { - return { valid: false, unknownKeys: [] }; + return invalidCheckSchema(); } return checkSchema(schema, val, {}, path); }, @@ -142,7 +142,7 @@ export function optionalOrNull(validator: Validator) { }, check: (val, path) => { if (val === undefined || val === null) { - return { valid: true, unknownKeys: [] }; + return successfulCheckSchema(); } return validator.check(val, path); }, @@ -161,7 +161,7 @@ export function optional(validator: Validator) { }, check: (val, path) => { if (val === undefined) { - return { valid: true, unknownKeys: [] }; + return successfulCheckSchema(); } return validator.check(val, path); }, @@ -214,13 +214,33 @@ export interface CheckSchemaResult { unknownKeys: string[]; } +/** + * Convenience function to produce a `CheckSchemaResult` where `valid: true`. + */ +function successfulCheckSchema(): CheckSchemaResult { + return { + valid: true, + unknownKeys: [], + }; +} + +/** + * Convenience function to produce a `CheckSchemaResult` where `valid: false`. + */ +function invalidCheckSchema(): CheckSchemaResult { + return { + valid: false, + unknownKeys: [], + }; +} + export function checkSchema( schema: S, obj: UnvalidatedObject, options: CheckSchemaOptions = {}, path: string = "", ): CheckSchemaResult { - const result: CheckSchemaResult = { valid: true, unknownKeys: [] }; + const result: CheckSchemaResult = successfulCheckSchema(); const inputKeys = new Set(Object.keys(obj)); for (const [key, validator] of Object.entries(schema)) { From 9a1225df6182678c271eb9c2500302669150d86a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 14:21:41 +0100 Subject: [PATCH 08/12] Track invalid keys, and use more standard JSON path notation --- lib/entry-points.js | 19 +++++++++++++++---- src/json/index.test.ts | 18 +++++++++++++++++- src/json/index.ts | 34 ++++++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index e8b673085b..1aa30ba067 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144509,7 +144509,7 @@ function isStringOrUndefined(value) { return value === void 0 || isString(value); } function defaultCheck(validate) { - return (arg) => ({ unknownKeys: [], valid: validate(arg) }); + return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) }); } function makeValidator(validate, required = true) { return { @@ -144541,15 +144541,18 @@ function validateSchema(schema, obj) { function successfulCheckSchema() { return { valid: true, - unknownKeys: [] + 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) { result.valid = false; if (options.failFast) { @@ -144565,8 +144568,12 @@ function checkSchema(schema, obj, options = {}, path29 = "") { continue; } if (hasKey) { - const checkResult = validator.check(obj[key], `${path29}${key}.`); + const checkResult = validator.check(obj[key], `${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) { @@ -144575,9 +144582,13 @@ function checkSchema(schema, obj, options = {}, path29 = "") { continue; } } + invalidKeys.delete(key); } for (const remainingKey of inputKeys) { - result.unknownKeys.push(`${path29}${remainingKey}`); + result.unknownKeys.push(`${path29}.${remainingKey}`); + } + for (const invalidKey of invalidKeys) { + result.invalidKeys.push(`${path29}.${invalidKey}`); } return result; } diff --git a/src/json/index.test.ts b/src/json/index.test.ts index 47956c9387..df682fd8b9 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -121,6 +121,22 @@ test("validateSchema - checkSchema reports unknown keys", async (t) => { t.true(result.valid); t.deepEqual( result.unknownKeys.sort(), - ["extraKey", "rootKey.nestedExtraKey"].sort(), + [".extraKey", ".rootKey.nestedExtraKey"].sort(), + ); +}); + +test("validateSchema - 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(), ); }); diff --git a/src/json/index.ts b/src/json/index.ts index 7bc23271e5..a5c89f722e 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -55,7 +55,7 @@ export type Validator = { function defaultCheck( validate: (val: unknown) => val is any, ): (arg: unknown) => CheckSchemaResult { - return (arg) => ({ unknownKeys: [], valid: validate(arg) }); + return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) }); } function makeValidator( @@ -88,20 +88,24 @@ export function array(validator: Validator) { check: (val: unknown, 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 eResult = validator.check(e, `${path}[${index}].`); + const elementPath = `${path}[${index}]`; + const eResult = validator.check(e, `${elementPath}`); result.unknownKeys.push(...eResult.unknownKeys); index++; if (!eResult.valid) { result.valid = false; + result.invalidKeys.push(elementPath); continue; } } @@ -212,6 +216,8 @@ export interface CheckSchemaResult { valid: boolean; /** Unknown keys that were found during validation. */ unknownKeys: string[]; + /** Known keys that failed validation. */ + invalidKeys: string[]; } /** @@ -221,6 +227,7 @@ function successfulCheckSchema(): CheckSchemaResult { return { valid: true, unknownKeys: [], + invalidKeys: [], }; } @@ -231,6 +238,7 @@ function invalidCheckSchema(): CheckSchemaResult { return { valid: false, unknownKeys: [], + invalidKeys: [], }; } @@ -242,6 +250,7 @@ export function checkSchema( ): CheckSchemaResult { const result: CheckSchemaResult = successfulCheckSchema(); const inputKeys = new Set(Object.keys(obj)); + const invalidKeys = new Set(); for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; @@ -249,6 +258,10 @@ export function checkSchema( // 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) { result.valid = false; @@ -271,9 +284,16 @@ export function checkSchema( // If the property is present, validate it. if (hasKey) { - const checkResult = validator.check(obj[key], `${path}${key}.`); + const checkResult = validator.check(obj[key], `${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; @@ -286,11 +306,17 @@ export function checkSchema( } // If we reach this point, the key has been successfully validated. + invalidKeys.delete(key); } // If there are any remaining keys in `inputKeys`, add them to `unknownKeys`. for (const remainingKey of inputKeys) { - result.unknownKeys.push(`${path}${remainingKey}`); + 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; From ba5fbf159aa9e3107b3b5df37853415b9e26835f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Jul 2026 14:24:33 +0100 Subject: [PATCH 09/12] Propagate `CheckSchemaOptions` to allow `failFast` to work as intended --- lib/entry-points.js | 6 +++--- src/json/index.ts | 29 +++++++++++++++++++---------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 1aa30ba067..aefa923c02 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144525,11 +144525,11 @@ function optionalOrNull(validator) { validate: (val) => { return val === void 0 || val === null || validator.validate(val); }, - check: (val, path29) => { + check: (val, opts, path29) => { if (val === void 0 || val === null) { return successfulCheckSchema(); } - return validator.check(val, path29); + return validator.check(val, opts, path29); }, required: false }; @@ -144568,7 +144568,7 @@ function checkSchema(schema, obj, options = {}, path29 = "") { continue; } if (hasKey) { - const checkResult = validator.check(obj[key], `${path29}.${key}`); + const checkResult = validator.check(obj[key], options, `${path29}.${key}`); result.unknownKeys.push(...checkResult.unknownKeys); result.invalidKeys.push(...checkResult.invalidKeys); if (checkResult.invalidKeys.length > 0) { diff --git a/src/json/index.ts b/src/json/index.ts index a5c89f722e..590a1eb6b3 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -48,7 +48,11 @@ export function isStringOrUndefined( */ export type Validator = { validate: (val: unknown) => val is T; - check: (val: unknown, path: string) => CheckSchemaResult; + check: ( + val: unknown, + opts: CheckSchemaOptions, + path: string, + ) => CheckSchemaResult; required: boolean; }; @@ -85,7 +89,7 @@ export function array(validator: Validator) { }; return { validate, - check: (val: unknown, path: string) => { + check: (val: unknown, opts: CheckSchemaOptions, path: string) => { const result: CheckSchemaResult = successfulCheckSchema(); // The value must be an array. @@ -98,7 +102,7 @@ export function array(validator: Validator) { let index = 0; for (const e of val) { const elementPath = `${path}[${index}]`; - const eResult = validator.check(e, `${elementPath}`); + const eResult = validator.check(e, opts, `${elementPath}`); result.unknownKeys.push(...eResult.unknownKeys); index++; @@ -106,6 +110,11 @@ export function array(validator: Validator) { if (!eResult.valid) { result.valid = false; result.invalidKeys.push(elementPath); + + if (opts.failFast) { + return result; + } + continue; } } @@ -125,11 +134,11 @@ export function object< validate: (val: unknown) => { return isObject(val) && validateSchema(schema, val); }, - check: (val, path) => { + check: (val, opts, path) => { if (!isObject(val)) { return invalidCheckSchema(); } - return checkSchema(schema, val, {}, path); + return checkSchema(schema, val, opts, path); }, required: true, } as const satisfies Validator; @@ -144,11 +153,11 @@ export function optionalOrNull(validator: Validator) { validate: (val: unknown) => { return val === undefined || val === null || validator.validate(val); }, - check: (val, path) => { + check: (val, opts, path) => { if (val === undefined || val === null) { return successfulCheckSchema(); } - return validator.check(val, path); + return validator.check(val, opts, path); }, required: false, } as const satisfies Validator; @@ -163,11 +172,11 @@ export function optional(validator: Validator) { validate: (val: unknown): val is T | undefined => { return val === undefined || validator.validate(val); }, - check: (val, path) => { + check: (val, opts, path) => { if (val === undefined) { return successfulCheckSchema(); } - return validator.check(val, path); + return validator.check(val, opts, path); }, required: false, } as const satisfies Validator; @@ -284,7 +293,7 @@ export function checkSchema( // If the property is present, validate it. if (hasKey) { - const checkResult = validator.check(obj[key], `${path}.${key}`); + const checkResult = validator.check(obj[key], options, `${path}.${key}`); result.unknownKeys.push(...checkResult.unknownKeys); result.invalidKeys.push(...checkResult.invalidKeys); From 89343ee1aace9f33f7489527e8d566f907e71ec0 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 10:22:19 +0100 Subject: [PATCH 10/12] Fix `checkSchema` test names --- src/json/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/json/index.test.ts b/src/json/index.test.ts index df682fd8b9..80edbedece 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -107,7 +107,7 @@ const checkSchemaTestSchema = { rootKey: json.object(objectSchema), }; -test("validateSchema - checkSchema reports unknown keys", async (t) => { +test("checkSchema - reports unknown keys", async (t) => { const result = json.checkSchema(checkSchemaTestSchema, { rootKey: { objectKey: { @@ -125,7 +125,7 @@ test("validateSchema - checkSchema reports unknown keys", async (t) => { ); }); -test("validateSchema - checkSchema reports invalid keys", async (t) => { +test("checkSchema - reports invalid keys", async (t) => { const result = json.checkSchema(checkSchemaTestSchema, { rootKey: { objectKey: { From 3f1e4da48dc2f81e92a17fb80b28102e0ece722a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 10:23:24 +0100 Subject: [PATCH 11/12] Propagate and prefer `invalidKeys` from element validator in `array` validator --- src/json/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/json/index.ts b/src/json/index.ts index 590a1eb6b3..2d50ff1217 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -104,12 +104,18 @@ export function array(validator: Validator) { 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; - result.invalidKeys.push(elementPath); + + // 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; From 0c2cea31ba55de6dab97ca4fc06e27396fb71841 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 14:47:38 +0100 Subject: [PATCH 12/12] Break out of loop to allow `result` object to be updated after failing fast --- lib/entry-points.js | 6 +++--- src/json/index.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index aefa923c02..083567dfb2 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144556,14 +144556,14 @@ function checkSchema(schema, obj, options = {}, path29 = "") { if (validator.required && !hasKey) { result.valid = false; if (options.failFast) { - return result; + break; } continue; } if (validator.required && (obj[key] === void 0 || obj[key] === null)) { result.valid = false; if (options.failFast) { - return result; + break; } continue; } @@ -144577,7 +144577,7 @@ function checkSchema(schema, obj, options = {}, path29 = "") { if (!checkResult.valid) { result.valid = false; if (options.failFast) { - return result; + break; } continue; } diff --git a/src/json/index.ts b/src/json/index.ts index 2d50ff1217..78923f8bac 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -264,9 +264,16 @@ export function checkSchema( 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; @@ -282,7 +289,7 @@ export function checkSchema( result.valid = false; if (options.failFast) { - return result; + break; } continue; } @@ -292,7 +299,7 @@ export function checkSchema( result.valid = false; if (options.failFast) { - return result; + break; } continue; } @@ -314,7 +321,7 @@ export function checkSchema( result.valid = false; if (options.failFast) { - return result; + break; } continue; }