Skip to content

fix this[] shallow resolution cycle in self-referential types#1618

Open
yharaskrik wants to merge 1 commit into
arktypeio:mainfrom
yharaskrik:jay/fix-this-array-shallow-cycle
Open

fix this[] shallow resolution cycle in self-referential types#1618
yharaskrik wants to merge 1 commit into
arktypeio:mainfrom
yharaskrik:jay/fix-this-array-shallow-cycle

Conversation

@yharaskrik
Copy link
Copy Markdown

Fix shallow resolution cycle on this[] self-references

Resolves #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:

ParseError: Alias 'type84' has a shallow resolution cycle: type84->type84

Scalar 'this' works. Only the array form trips the cycle detector. A minimal repro:

import { type } from "arktype"

const WhereInput = type({
	"AND?": "this[]",
	"OR?": "this[]",
	"NOT?": "this",
	name: "string"
})
//  ParseError: Alias 'type<N>' has a shallow resolution cycle

Both 'AND?' and 'OR?' produce the same this[] 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/graphql decorators over to ArkType-backed schemas via a custom codemod.

A huge chunk of the schema is Prisma-style WhereInput shapes that look exactly like the repro above: AND, OR, NOT fields 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 inline Type siblings). Although, I am not sure that would have actually worked since I only started working with ArkType last week (when deciding to move from class-validator to 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 running bootstrapAliasReferences / precompile on a node, check whether any of its references is an alias whose target is still a context node. 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 that this / this[] produce. Real shallow cycles (e.g. scope({ a: "b", b: "a" })) still throw, because they go through the $name path that this guard deliberately leaves alone.

Verifying the fix

  • Added regression tests in ark/type/__tests__/this.test.ts covering the 'this[]' shape from the repro and the AND/OR union-of-self pattern.
  • All existing this.test.ts cases still pass.
  • Full pnpm prChecks matrix 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).
  • We patched the compiled output from this PR into our monorepo via pnpm patch and re-ran the migration. Every previously broken recursive WhereInput now 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 hasUnresolvedContextAlias

5M iterations × 5 trials per cell, median ns/call. Run 3 of 3 (consistent across all runs):

variant standard (no aliases) cyclic in-progress (this[]) resolved cyclic scope $-alias only
V1 ABC (current) 51.66 33.88 36.12 22.36
V2 ACB 59.09 38.54 38.80 30.98
V3 BAC 73.39 47.40 50.77 26.89
V4 BCA 112.23 60.12 62.07 34.66
V5 CAB 94.35 50.17 49.38 35.96
V6 CBA 95.60 51.31 49.29 35.50

V1 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).


  • Code is up-to-date with the main branch
  • You've successfully run pnpm prChecks locally
  • There are new or updated unit tests validating the change

@github-project-automation github-project-automation Bot moved this to To do in arktypeio May 10, 2026
@yharaskrik yharaskrik force-pushed the jay/fix-this-array-shallow-cycle branch from c2eae4b to 7a6bb5a Compare May 10, 2026 21:13
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

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 referencesBaseScope.finalize now early-returns when hasUnresolvedContextAlias(node) is true, letting the outer parse complete bootstrap and precompile after the context is replaced.
  • Add hasUnresolvedContextAlias helper — walks node.references for an alias node whose non-$-prefixed reference still resolves to a context kind in nodesByRegisteredId.
  • Regression tests for this[] — covers "children?": "this[]" and the AND/OR union-of-self pattern from #1406.

Summary | 2 files | 1 commit | base: mainjay/fix-this-array-shallow-cycle


Why this[] tripped the cycle detector but this did not

Before: BaseRoot.array() called $.schema(...) mid-parse, which invoked finalizebootstrapAliasReferencesaliasNode.resolution, hitting AliasNode._resolve while nodesByRegisteredId[ctx.id] still pointed at the context itself, producing Alias 'typeN' has a shallow resolution cycle: typeN->typeN.
After: finalize detects that situation via hasUnresolvedContextAlias, returns the node un-bootstrapped, and the enclosing parseDefinition finalizes once nodesByRegisteredId[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.

ark/schema/scope.ts


Coverage of the relaxed code path

Before: Every finalize invocation would unconditionally bootstrap and precompile, so any spurious or real shallow-cycle would surface immediately at bootstrapAliasReferences.
After: The early-return path skips bootstrap entirely when an unresolved context alias is present; real shallow cycles like scope({ a: "b", b: "a" }) still throw via the $-prefixed _resolve path, 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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread ark/schema/scope.ts Outdated
Comment thread ark/schema/scope.ts
Comment thread ark/type/__tests__/this.test.ts
Comment thread ark/type/__tests__/this.test.ts
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

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: mainjay/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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@yharaskrik yharaskrik force-pushed the jay/fix-this-array-shallow-cycle branch from 7a6bb5a to 3990bd0 Compare May 10, 2026 21:26
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>
@yharaskrik yharaskrik force-pushed the jay/fix-this-array-shallow-cycle branch from 3990bd0 to 3ca8878 Compare May 10, 2026 21:52
@@ -0,0 +1,62 @@
import { attest, contextualize } from "@ark/attest"
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I am not sure this is actually helpful, was added based on some feedback from pullfrog. Let me know what you think.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To do

Development

Successfully merging this pull request may close these issues.

Shallow cycle error on this[]

1 participant