From 2f6a097fe4141a16d29cf96550548cc958e926cf Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 13 Jul 2026 17:26:38 +0800 Subject: [PATCH] fix(schema): prevent crash validating unions of object arrays When a union of array types with object element bases (e.g. `{ objB: string }[] | { objC: string }[]`) was validated against an array containing a non-object element, a `TypeError: Cannot use 'in' operator` was thrown. Inside a union branch, traversal runs in fail-fast mode and `ctx.currentErrorCount` saturates at 1, so an intersection's basis (object/proto) failure guard (`currentErrorCount > errorCount`) could not detect a newly added error once an earlier element in the same branch had already errored. The sequence node then continued iterating and applied the element's structure check (`key in data`) to a primitive, throwing. SequenceNode now mirrors StructureNode by bailing out of remaining elements in fail-fast mode, both in `traverseApply` and in its compiled output, so a failed element basis short-circuits before the unguarded `in` check runs. Top-level (non-fail-fast) validation is unchanged and still collects every element error. Closes #1458 --- ark/schema/structure/sequence.ts | 22 ++++++++++++++++++++-- ark/type/__tests__/union.test.ts | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 527bdbc95b..f8b605a86c 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -419,6 +419,7 @@ export class SequenceNode extends BaseConstraint { } traverseApply: TraverseApply = (data, ctx) => { + const errorCount = ctx.currentErrorCount let i = 0 for (; i < data.length; i++) { traverseKey( @@ -426,6 +427,10 @@ export class SequenceNode extends BaseConstraint { () => this.elementAtIndex(data, i).node.traverseApply(data[i], ctx), ctx ) + // bail out of subsequent elements in fail-fast mode (e.g. inside a + // union branch) so we don't traverse an element whose basis has + // already failed - see https://github.com/arktypeio/arktype/issues/1458 + if (ctx.failFast && ctx.currentErrorCount > errorCount) return } } @@ -435,9 +440,17 @@ export class SequenceNode extends BaseConstraint { // minLength/maxLength compilation should be handled by Intersection compile(js: NodeCompiler): void { + // like Structure, bail out of subsequent elements in fail-fast mode + // (e.g. inside a union branch) so we don't traverse an element whose + // basis has already failed - see + // https://github.com/arktypeio/arktype/issues/1458 + if (js.traversalKind === "Apply") js.initializeErrorCount() + if (this.prefix) { - for (const [i, node] of this.prefix.entries()) + for (const [i, node] of this.prefix.entries()) { js.traverseKey(`${i}`, `data[${i}]`, node) + if (js.traversalKind === "Apply") js.returnIfFailFast() + } } for (const [i, node] of this.defaultablesAndOptionals.entries()) { @@ -446,6 +459,7 @@ export class SequenceNode extends BaseConstraint { js.traversalKind === "Allows" ? js.return(true) : js.return() ) js.traverseKey(dataIndex, `data[${dataIndex}]`, node) + if (js.traversalKind === "Apply") js.returnIfFailFast() } if (this.variadic) { @@ -457,13 +471,17 @@ export class SequenceNode extends BaseConstraint { } js.for( `i < ${this.postfix ? "firstPostfixIndex" : "data.length"}`, - () => js.traverseKey("i", "data[i]", this.variadic!), + () => { + js.traverseKey("i", "data[i]", this.variadic!) + return js.traversalKind === "Apply" ? js.returnIfFailFast() : js + }, this.prevariadic.length ) if (this.postfix) { for (const [i, node] of this.postfix.entries()) { const keyExpression = `firstPostfixIndex + ${i}` js.traverseKey(keyExpression, `data[${keyExpression}]`, node) + if (js.traversalKind === "Apply") js.returnIfFailFast() } } } diff --git a/ark/type/__tests__/union.test.ts b/ark/type/__tests__/union.test.ts index 7a0925da39..44a6a42009 100644 --- a/ark/type/__tests__/union.test.ts +++ b/ark/type/__tests__/union.test.ts @@ -48,6 +48,27 @@ contextualize(() => { attest(T.json).equals(type("string|bigint|number|boolean").json) }) + it("union of object arrays doesn't throw on non-object element", () => { + // https://github.com/arktypeio/arktype/issues/1458 + // checking a later element whose basis (object) fails must not attempt + // an `in` check against a primitive earlier collected element + const TypeAB = type.or("string", { objB: "string" }) + const TypeC = type({ objC: "string" }) + const TypeABC = type([TypeAB.array(), "|", TypeC.array()]) + + attest( + TypeABC([{ objB: "text b" }, "test a", { objC: "text c" }]).toString() + ).snap( + "value at [2].objB must be a string (was missing) or [0].objC must be a string (was missing)" + ) + + attest( + TypeABC(["test a", { objB: "text b" }, { objC: "text c" }]).toString() + ).snap( + "value at [2].objB must be a string (was missing) or [0] must be an object (was a string)" + ) + }) + it("length stress", () => { // as of TS 5.1, can handle a max of 46 branches before an inifinitely // deep error not the end of the world if this changes slightly, but