diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 527bdbc95..f8b605a86 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 7a0925da3..44a6a4200 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