Repository navigation
Conversation
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.
There was a problem hiding this comment.
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).
intersectConstraintsmulti-reduction —ark/schema/constraint.ts: whenheadreduces 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 formin/max,minLength/maxLength, andafter/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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| // 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) |
There was a problem hiding this comment.
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?…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>
* 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>
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>

Intersecting a length bound with a range that it narrows to a single length throws an internal error:
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")is2).In
intersectConstraints, the new>= 2reduces with both existing bounds: with<= 2to== 2and with>= 1to>= 2. For numbers and dates, the first result is a unit, which takes theisRoot()path, but for lengths it's anexactLengthconstraint, 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 shrinkslby one, so it terminates, and it only runs where the code used to throw.This also blocks
.outon 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 originalminLength: 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 ordersark/type/__tests__/range.test.ts: "== length from narrowing a length range" for strings and arraysOn
mainthese 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 passingpnpm test: 1795 passingpnpm typecheckRepo: no errorsprettier --checkandeslinton the changed files: cleanNot 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.