From 43904af416b277c018477dd4e4af80d33f85cd2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:30:37 +0000 Subject: [PATCH 1/2] fix: check sparse-array holes in the isArrayOf type guard The boolean path used Array.prototype.every(), which skips holes, while validate() and validateAll() visit every index. The guard now visits every index too, so a hole is checked as undefined on all paths. Closes #450 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019BJENnU8SXcDH2Tcx5EWE4 --- src/typeChecker.ts | 15 +++++++++++++-- src/validate.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/typeChecker.ts b/src/typeChecker.ts index 35f67e97..f681ee65 100644 --- a/src/typeChecker.ts +++ b/src/typeChecker.ts @@ -236,8 +236,19 @@ export const isArrayOf: ( const k = `isArrayOf${typeName}`; return { typeGuard: { - [k]: (v: unknown): v is Array => - Array.isArray(v) && v.every((i) => isT(i)), + // Visit every index (holes included) to match the diagnosis path; + // Array.prototype.every() would skip holes. + [k]: (v: unknown): v is Array => { + if (!Array.isArray(v)) { + return false; + } + for (let index = 0; index < v.length; index++) { + if (!isT(v[index])) { + return false; + } + } + return true; + }, }[k], *serialize(depth, done) { yield "Array<"; diff --git a/src/validate.test.ts b/src/validate.test.ts index 28dd99b2..c8de7f15 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -9,7 +9,7 @@ import { isOptionalOf, typeChecker, } from "./typeChecker.ts"; -import type { ValidationIssue } from "./types.ts"; +import type { TypeChecker, ValidationIssue } from "./types.ts"; import { validate, validateAll } from "./validate.ts"; import { ValidationIssueCode, @@ -406,3 +406,39 @@ test("exact object validation preserves its input without sanitizing it", () => assert.deepEqual(Object.getOwnPropertyDescriptors(input), before); assert.equal(Object.isFrozen(input), true); }); + +const assertArrayResult = ( + checker: TypeChecker>, + input: unknown, + expected: boolean, +): void => { + const label = `${checker} ${JSON.stringify(input)}`; + assert.equal(checker(input), expected, label); + assert.equal(checker.test(input) === null, expected, label); + assert.equal(validate(input, checker).ok, expected, label); + assert.equal(validateAll(input, checker).ok, expected, label); +}; + +test("array holes are checked like undefined elements on every path", () => { + const isStrings = isArrayOf(isString); + const isOptionalStrings = isArrayOf(isOptionalOf(isString)); + // biome-ignore lint/suspicious/noSparseArray: sparse arrays are the subject + const mixed = ["a", , "c"]; + const cases: Array<[unknown, boolean, boolean]> = [ + // [input, isStrings, isOptionalStrings] + [[], true, true], + [new Array(2), false, true], + [mixed, false, true], + [["a", undefined], false, true], + [["a", "b"], true, true], + ]; + for (const [input, strings, optionalStrings] of cases) { + assertArrayResult(isStrings, input, strings); + assertArrayResult(isOptionalStrings, input, optionalStrings); + } + const result = validate(mixed, isStrings); + assert.equal(result.ok, false); + if (!result.ok) { + assert.deepEqual(result.issue.path, [1]); + } +}); From bba174251c74b8b66a07e26ba7683756ce669b24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:13:08 +0000 Subject: [PATCH 2/2] fix: keep the isArrayOf hole check within the bundle budget After merging main, the explicit index loop pushed the minified UUID checker bundle to 5924 bytes, over the 5916-byte budget enforced by test/package.test.ts. findIndex() also visits holes as undefined, and keeps the bundle at 5877 bytes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019BJENnU8SXcDH2Tcx5EWE4 --- src/typeChecker.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/typeChecker.ts b/src/typeChecker.ts index 67c461c1..11c159b2 100644 --- a/src/typeChecker.ts +++ b/src/typeChecker.ts @@ -236,19 +236,10 @@ export const isArrayOf: ( const k = `isArrayOf${typeName}`; return { typeGuard: { - // Visit every index (holes included) to match the diagnosis path; - // Array.prototype.every() would skip holes. - [k]: (v: unknown): v is Array => { - if (!Array.isArray(v)) { - return false; - } - for (let index = 0; index < v.length; index++) { - if (!isT(v[index])) { - return false; - } - } - return true; - }, + // findIndex() visits holes as undefined, matching the diagnosis + // path; every() would skip them. + [k]: (v: unknown): v is Array => + Array.isArray(v) && v.findIndex((i) => !isT(i)) < 0, }[k], *serialize(depth, done) { yield "Array<";