fix this[] shallow resolution cycle in self-referential types#1618
fix this[] shallow resolution cycle in self-referential types#1618yharaskrik wants to merge 1 commit into
this[] shallow resolution cycle in self-referential types#1618Conversation
c2eae4b to
7a6bb5a
Compare
There was a problem hiding this comment.
Important
Consider gating the new check on node.isCyclic so non-cyclic finalize paths skip the reference walk, and adding the missing static-type and negative-case test assertions before merge. The fix itself is sound — the deferred-finalize node is always re-finalized by the enclosing parse, the $-prefix carve-out correctly preserves real shallow-cycle detection, and nodesByRegisteredId[ref.reference] is safe when undefined.
TL;DR — Fixes the spurious shallow resolution cycle thrown when a self-referential type uses this[] (the array form of this) by deferring finalization of any node whose references still point to an in-progress context node. The enclosing parse runs the deferred finalize once it has registered the resolved root.
Key changes
- Defer finalize on unresolved-context references —
BaseScope.finalizenow early-returns whenhasUnresolvedContextAlias(node)is true, letting the outer parse complete bootstrap and precompile after the context is replaced. - Add
hasUnresolvedContextAliashelper — walksnode.referencesfor analiasnode whose non-$-prefixed reference still resolves to acontextkind innodesByRegisteredId. - Regression tests for
this[]— covers"children?": "this[]"and theAND/ORunion-of-self pattern from #1406.
Summary | 2 files | 1 commit | base: main ← jay/fix-this-array-shallow-cycle
Why this[] tripped the cycle detector but this did not
Before:
BaseRoot.array()called$.schema(...)mid-parse, which invokedfinalize→bootstrapAliasReferences→aliasNode.resolution, hittingAliasNode._resolvewhilenodesByRegisteredId[ctx.id]still pointed at the context itself, producingAlias 'typeN' has a shallow resolution cycle: typeN->typeN.
After:finalizedetects that situation viahasUnresolvedContextAlias, returns the node un-bootstrapped, and the enclosingparseDefinitionfinalizes oncenodesByRegisteredId[ctx.id]has been overwritten with the resolved root.
Bare "this" never tripped this because it does not call any operator (array(), or(), etc.) that re-enters finalize mid-parse — it just produces a single alias node consumed by the outer parse.
Coverage of the relaxed code path
Before: Every
finalizeinvocation would unconditionally bootstrap and precompile, so any spurious or real shallow-cycle would surface immediately atbootstrapAliasReferences.
After: The early-return path skips bootstrap entirely when an unresolved context alias is present; real shallow cycles likescope({ a: "b", b: "a" })still throw via the$-prefixed_resolvepath, but nothing in the test suite asserts that.
A grep for writeShallowCycleErrorMessage / resolution cycle across the repo returns zero test hits. Since this PR is the first to relax the condition that produces that error, a single negative-case assertion that real shallow cycles still throw would lock down the regression boundary.
ark/type/__tests__/this.test.ts · ark/schema/roots/alias.ts
Claude Opus | 𝕏
There was a problem hiding this comment.
No new issues found.
TL;DR — Incremental change is a formatting-only collapse of a single attest(...) call onto one line in the new test. No semantic effect.
Summary | 1 file | 1 commit (amended) | base: main ← jay/fix-this-array-shallow-cycle
The earlier review's recommendations (isCyclic short-circuit, invariant comment on the $-prefix carve-out, static-type assertions, real-shallow-cycle negative test) still apply.
Claude Opus | 𝕏
7a6bb5a to
3990bd0
Compare
Defer scope finalization when a node still contains aliases pointing to in-progress context nodes. Previously, parsing `'this[]'` wrapped the alias in `array()`, which eagerly called `finalize()` → `bootstrapAliasReferences()` before the enclosing context had been replaced with the resolved root, falsely tripping the shallow-cycle detector. The outer parse now runs the deferred finalization once the context has been resolved. Closes arktypeio#1406 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3990bd0 to
3ca8878
Compare
| @@ -0,0 +1,62 @@ | |||
| import { attest, contextualize } from "@ark/attest" | |||
There was a problem hiding this comment.
I am not sure this is actually helpful, was added based on some feedback from pullfrog. Let me know what you think.

Fix shallow resolution cycle on
this[]self-referencesResolves #1406.
A note on AI assistance
I drafted this fix with AI assistance. The diagnosis, the patch, the regression tests, and this description all went through that loop. I have reviewed every change manually to the best of my ability, I made sure to reproduce the bug first with a test and then with the help of AI fix it after, traced the cycle through the alias / context-id machinery, and confirmed the fix in our own codebase. I am happy to make any changes necessary.
What this fixes
When a schema's only self-reference is
'this[]'(an array of the enclosing type), ArkType throws at parse time:Scalar
'this'works. Only the array form trips the cycle detector. A minimal repro:Both
'AND?'and'OR?'produce the samethis[]reference. During finalization, the alias resolution walks the references for the node and sees one that still points at the in-progress context node (the enclosing type hasn't been swapped in yet), and bails with the shallow-cycle error. There's no real cycle, just a finalization-order problem.Why this matters (our use case)
At Trellis, we run a large Nx monorepo with ~1,200 libraries and a GraphQL surface generated from Prisma models. We're in the middle of migrating that surface from class-validator +
@nestjs/graphqldecorators over to ArkType-backed schemas via a custom codemod.A huge chunk of the schema is Prisma-style
WhereInputshapes that look exactly like the repro above:AND,OR,NOTfields that reference the enclosing input type as both a single value and an array.We considered rewriting the codemod to emit
scope({...}).export()for every recursive type just to dodge this codepath, which would have meant hoisting every.describe(...)'d field into a const (scope literals don't accept inlineTypesiblings). Although, I am not sure that would have actually worked since I only started working with ArkType last week (when deciding to move fromclass-validatorto ArkType after running some benchmarks.The fix
The cycle detector is correct that we shouldn't finalize a node whose references still point to an in-progress context node, but the right response in that case is to defer finalization instead of throwing. The enclosing parse already has a finalization step that runs once the context has been replaced with the resolved node. This PR lets that finalize complete the work that was deferred.
In
ark/schema/scope.ts, before runningbootstrapAliasReferences/ precompile on a node, check whether any of its references is an alias whose target is still acontextnode. If so, return early and let the enclosing parse finalize.The
ref.reference[0] !== '$'guard skips user-named aliases (those route through scope resolution), so this only affects the synthetic context aliases thatthis/this[]produce. Real shallow cycles (e.g.scope({ a: "b", b: "a" })) still throw, because they go through the$namepath that this guard deliberately leaves alone.Verifying the fix
ark/type/__tests__/this.test.tscovering the'this[]'shape from the repro and theAND/ORunion-of-self pattern.this.test.tscases still pass.pnpm prChecksmatrix is green locally: lint, typecheck, mocha, V8 fast-property check, integration tests, attest tests, build, docs build, all benches (cyclic deltas of +0.00 to +0.16 percent, please let me know if this is acceptable), and the TS version matrix (5.1.6 / 5.9.3 / 5.9.0-dev).pnpm patchand re-ran the migration. Every previously broken recursiveWhereInputnow parses, validates, and round-trips. The shallow-cycle error is gone across the board. The downstream issues we still see are unrelated (our own factory glue), not ArkType.Performance Considerations
Since I know performance is incredibly important (especially in this library), I tested the performance of the ordering of conditionals in the function that was added
Microbench results: ordering of conditionals in
hasUnresolvedContextAlias5M iterations × 5 trials per cell, median ns/call. Run 3 of 3 (consistent across all runs):
this[])$-alias onlyV1 ABC (the current ordering) is fastest in every scenario, including the standard case by a meaningful margin (~13% over V2, ~40% over V3, ~120% over V4).
mainbranchpnpm prCheckslocally