Skip to content

fix(schema): reduce a length bound that narrows an existing range - #1697

Closed
ybai08 wants to merge 1 commit into
arktypeio:mainfrom
ybai08:fix/length-bound-intersection
Closed

ybai08 wants to merge 1 commit into
arktypeio:mainfrom
ybai08:fix/length-bound-intersection

Conversation

@ybai08

@ybai08 ybai08 commented Oct 17, 2026

Copy link
Copy Markdown

Intersecting a length bound with a range that it narrows to a single length throws an internal error:

type("1 <= string <= 2").and("string >= 2")
// Error: Unexpectedly encountered multiple distinct intersection results for refinement Type<>= 2>

The same thing happens for arrays (type("string[] <= 2").and("string[] >= 1").and("string[] >= 2")). The number and Date equivalents already work (type("1 <= number <= 2").and("number >= 2") is 2).

In intersectConstraints, the new >= 2 reduces with both existing bounds: with <= 2 to == 2 and with >= 1 to >= 2. For numbers and dates, the first result is a unit, which takes the isRoot() path, but for lengths it's an exactLength constraint, so the loop reached the "multiple distinct intersection results" branch. That branch now replaces the second constraint with its result and queues that result to be intersected with the rest (here == 2 & >= 2, which is == 2). It always shrinks l by one, so it terminates, and it only runs where the code used to throw.

This also blocks .out on a tuple with a default after a prefix element, e.g. type(["number", "string = 'foo'"]).out, since the output's implied length of 2 is reduced against the original minLength: 1 / maxLength: 2. That case also needs the separate tuple reduceIo fix in #1696. With both, it gives [number, string].

Tests

  • ark/schema/__tests__/bounds.test.ts: "unit range reduces when narrowing an existing range" for each of min/max, minLength/maxLength and after/before, in both operand orders
  • ark/type/__tests__/range.test.ts: "== length from narrowing a length range" for strings and arrays

On main these fail for lengths only (numbers and dates already pass). With this change they all pass.

What was run locally:

  • pnpm testFiles ark/type/__tests__/range.test.ts ark/schema/__tests__/bounds.test.ts (with type assertions): 86 passing
  • pnpm test: 1795 passing
  • pnpm typecheckRepo: no errors
  • prettier --check and eslint on the changed files: clean

Not run: the full pnpm prChecks (buildRepo, benches, testV8, testIntegration, testTsVersions, testTypedLoose). I didn't run the benchmarks because the change only touches the branch that used to throw.

Intersecting a length bound with a range it narrows to a single length,
like (1 <= string <= 2) & (string >= 2), reduced the new bound with both
existing bounds (to == 2 and >= 2) and threw an internal error instead
of intersecting those results. Queue the second result to be
intersected with the rest so the range reduces to string == 2, as it
already does for numbers and dates.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The core reduction fix is correct and well-tested, but removing the throwInternalError guard means type(["number", "string = 'foo'"]).out now silently returns an incorrect type instead of throwing. That is only correct once the paired #1696 lands, so please land them together (or keep a guard until then).

Reviewed changes

This run reviewed the single commit on fix/length-bound-intersection (3 files).

  • intersectConstraints multi-reduction — ark/schema/constraint.ts: when head reduces a second existing constraint to a distinct result, the branch now splices that constraint out and queues its result to be intersected with the rest, instead of throwing.
  • bounds tests — ark/schema/__tests__/bounds.test.ts: new "unit range reduces when narrowing an existing range" case for min/max, minLength/maxLength, and after/before, in both operand orders.
  • range tests — ark/type/__tests__/range.test.ts: new "== length from narrowing a length range" covering strings (expression/allows) and arrays (json).

The reduction logic itself checks out: pnpm test is 1795 passing, and exhaustive/randomized probing over all bound kinds found no input where the reduced node differs from the full conjunction or fails to terminate.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread ark/schema/constraint.ts
Comment on lines +205 to +210
// head can reduce more than one existing constraint, e.g. >= 2 with
// <= 2 (to == 2) and >= 1 (to >= 2), so replace this one with its
// result and queue that to be intersected with the rest
s.l.splice(i, 1)
i--
s.r.push(result as BaseConstraint)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this guard changes type(["number", "string = 'foo'"]).out from a thrown internal error into a silently incorrect type: it now yields [number], so out.allows([1, "foo"]) is false and out.allows([1]) is true. The intersection result here is only correct for the already-corrupted .out structure, which the paired #1696 fixes. Please land #1696 in the same release (or keep a guard / pin .out to [number, string] in a test) so this PR alone doesn't ship a wrong output type.

Technical details
# Removed guard lets an incorrect tuple `.out` ship

## Affected sites
- `ark/schema/constraint.ts:205-210` — the `throwInternalError` is replaced by a splice/requeue, so the previously-throwing `.out` path now returns a result
- `ark/schema/structure/sequence.ts:135-143` — `defaultables.reduceIo` assigns over `inner.prefix` on `out` instead of combining, dropping the surrounding prefix (this is the root cause, tracked by #1696)

## Evidence
```ts
type(["number", "string = 'foo'"]).out
// head: json {"sequence":{"prefix":["number"]},"proto":"Array","exactLength":1}
//       out.allows([1, "foo"]) === false, out.allows([1]) === true
// base (constraint.ts reverted): throws
//       "Unexpectedly encountered multiple distinct intersection results for refinement Type<<= 1>"
```
Control: `type(["number", "string"]).out` is `[number, string]` (exactLength 2).

## Required outcome
- The removed guard must not ship on its own, since it converts a loud internal error into a silently wrong public output type. Landing #1696 in the same release (or adding a guard/test that pins `.out` to `[number, string]`) resolves this.

## Open questions for the human
- Is #1696 expected to merge before/with this PR, or should the guard stay until then?

ssalbdivad added a commit to ybai08/arktype that referenced this pull request Oct 20, 2026
…nother

A bound can narrow two existing constraints at once: >= 2 meets <= 2 as
== 2 and >= 1 as >= 2. For numbers and Dates the first result is a unit,
but for lengths it is an exactLength constraint, so intersectConstraints
threw "multiple distinct intersection results" for
type("1 <= string <= 2").and("string >= 2").

The second narrowed constraint is now removed and its result queued to
be intersected with the rest. That also reduces
type("1 <= string <= 3").and("string == 2") to string == 2 instead of
leaving == 2 & >= 1. Together with the tuple reduceIo fix,
type(["number", "string = 'foo'"]).out is now [number, string]; on its
own, that case would silently reduce to [number].

From arktypeio#1697. The tuple reduceIo change also drops the merges inside
defaultables, which never ran: inner keys are sorted, so defaultables
always reduces before optionals and prefix.

Co-Authored-By: ybai08 <271969568+ybai08@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ssalbdivad added a commit that referenced this pull request Oct 20, 2026
* fix(schema): keep tuple elements around defaults in .in and .out

A tuple's defaultables are converted to optionals on input and prefix
elements on output, but reduceIo assigned them over the existing
optionals or prefix instead of combining them. Depending on key order,
this dropped either the defaultable or the surrounding elements, e.g.
[string, number = 5, boolean?] had an input of [string, boolean?] and
[number, string = "foo", ...number[]] had an output of number[].

* fix(schema): intersect again a constraint a bound narrows alongside another

A bound can narrow two existing constraints at once: >= 2 meets <= 2 as
== 2 and >= 1 as >= 2. For numbers and Dates the first result is a unit,
but for lengths it is an exactLength constraint, so intersectConstraints
threw "multiple distinct intersection results" for
type("1 <= string <= 2").and("string >= 2").

The second narrowed constraint is now removed and its result queued to
be intersected with the rest. That also reduces
type("1 <= string <= 3").and("string == 2") to string == 2 instead of
leaving == 2 & >= 1. Together with the tuple reduceIo fix,
type(["number", "string = 'foo'"]).out is now [number, string]; on its
own, that case would silently reduce to [number].

From #1697. The tuple reduceIo change also drops the merges inside
defaultables, which never ran: inner keys are sorted, so defaultables
always reduces before optionals and prefix.

Co-Authored-By: ybai08 <271969568+ybai08@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ybai08 <271969568+ybai08@users.noreply.github.com>
Co-authored-by: David Blass <david@arktype.io>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ssalbdivad

ssalbdivad commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

thanks @ybai08! landed this together with #1696 since the tuple .out case needs both. kept your tests and tweaked the fix slightly so type("1 <= string <= 3").and("string == 2") also reduces all the way to string == 2

@ssalbdivad ssalbdivad closed this Oct 20, 2026
@github-project-automation github-project-automation Bot moved this from To do to Done (merged or closed) in arktypeio Oct 20, 2026
ssalbdivad added a commit that referenced this pull request Oct 21, 2026
Ports the tests of 092075a's second fix from arktype 2.2.9 (#1696,
from #1697, @ybai08). There, a bound narrowing two constraints at once,
as `>= 2` meets `<= 2` as `== 2` and `>= 1` as `>= 2`, threw "multiple
distinct intersection results" for lengths, where the first result is an
exactLength rather than a unit:

    type("1 <= string <= 2").and("string >= 2")

This branch already intersects the second result again (d00d9dd),
tested only through a tuple intersecting itself. The range and bounds
tests come over unchanged and pass. The commit's tuple .in/.out fix came
with featherduster (540c78e).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done (merged or closed)

Development

Successfully merging this pull request may close these issues.

2 participants