Skip to content

fix(schema): let unused types be garbage collected (#1584, part B) - #2

Open
isaacwasserman wants to merge 2 commits into
budapest/arktype-1584-fixfrom
budapest/arktype-1584-fix-b
Open

isaacwasserman wants to merge 2 commits into
budapest/arktype-1584-fixfrom
budapest/arktype-1584-fix-b

Conversation

@isaacwasserman

@isaacwasserman isaacwasserman commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Problem

Part B of #1584. This PR is stacked on #1 (part A), which is merged upstream as arktypeio#1673.

ArkType kept each type that you make at runtime, even after you drop it. Global caches held the types strongly. Some examples of types that stayed in memory:

  • types with inline functions: .narrow(s => ...), .pipe(...)
  • types with keys made at runtime: type({ [key]: "string" })
  • string definitions made at runtime: type(`string > ${n}`)
  • recursive types: type({ box: "this | undefined" })

Five places kept each type:

  • the global $ark registry (functions, propsByKey objects, symbols)
  • namesByResolution
  • nodesByRegisteredId
  • scope.nodesByHash
  • intersectionCache

Fix

  • Compiled code no longer reads registered values from the global $ark. CompiledFunction.compile() passes the registry name ($ark) as the first parameter, and binds it to an object that holds only the values that the code uses. The parameter hides the global, so:

    • the generated code, the hashes and the JSON do not change,
    • registered values are freed together with the compiled code.

    register() now holds values weakly. Names that the object does not hold (e.g. $ark.intrinsic) come from the global registry through the object's prototype.

  • New WeakValueMap (@ark/util). This map holds its values weakly and removes the entries of freed values in amortized sweeps. It is used for:

    • nodesByRegisteredId
    • scope.nodesByHash
    • the string parseCache
    • the 2 default-value morph caches
  • intersectionCache is a nested WeakMap keyed on the attachments of both operands. An entry is removed when either operand is freed.

  • Parse contexts. A short-lived parse context is held strongly (WeakValueMap.setStrong) until its parse replaces or releases it. A WeakRef would keep it alive until the end of the current job, so a synchronous loop would keep each context (about 190 bytes per call). The context is also released if its parse throws, and a generic parameter context is released after its parse. Before this PR, both stayed in the registry forever. The context of a scope alias is held weakly, and the scope holds it until it is resolved.

  • lazilyResolve remembers its first result. Compiled code refers to the id of the result, and weak caches can no longer make sure that the result stays the same.

  • Names of global registry entries are reserved in register() (e.g. intrinsic, version). Before, a user function with such a name replaced the global entry. For intrinsic, this crashed ArkType.

Hashes contain registered names such as $ark.fn12. A name belongs to exactly one value, and names are never used again. So after a value is freed, no new node can have the same hash, and it is safe to use hashes as keys of weak-value maps.

WeakRef is not in ES2020. If WeakRef is not available, or for a Symbol.for symbol, ArkType holds values strongly (the old behavior).

Results

2000 types per case. Each type is validated one time, then dropped, then GC runs:

Case Types in memory (before → after) Heap growth (before → after)
.narrow(fn) 2000 → 0 +31 MB → +0.8 MB
.pipe(fn) 2000 → 0 +14 MB → +0.1 MB
key made at runtime 2000 → 0 +42 MB → +0.4 MB
{ box: "this | undefined" } 2000 → 0 +84 MB → +0.4 MB

With 30,000 .narrow calls, the heap grew to 567 MB before. After the fix, it goes up and down between 57 and 143 MB.

Repeated parses in one synchronous loop (20,000 calls each, bytes per call, before → after): type("string") 0 → 2, T.pick("a") 7 → 7, parse error 234 → 5, generic 196 → 30.

Limit: a WeakRef target can be freed only after the current job ends. So new types that a fully synchronous loop makes are freed when the loop ends or yields (await), not during the loop.

Speed

  • Validation (pnpm benchRuntime): no measurable change.
  • Parsing a type that is already cached: equal or faster (e.g. T.and(U): 0.9 µs → 0.5 µs).
  • Parsing a new type: about 5–15% slower, from WeakRef work. The benchmark had large variation between runs.

Breaking changes (internal API)

  • $ark.nodesByRegisteredId and scope.nodesByHash are now WeakValueMaps (get, set, delete, keys, size), not plain objects.
  • Registered values (e.g. $ark.fn12) are no longer properties of the global $ark.

Tests

Added to ark/type/__tests__/registry.test.ts:

  • 8 GC tests: narrow, pipe, runtime key, runtime string, default value, recursive, recursive intersection, scope. Each test makes 20 types, drops them, runs GC, and checks that no type stays in memory. All 8 fail without this fix.
  • 1 test that checks that existing types still validate correctly after GC frees other types.
  • 1 test that checks that 20,000 type("string") calls in one synchronous job keep no memory. It fails if parse contexts are held weakly.
  • 4 tests for registered values: compiled code finds weakly held values, a symbol key, a Symbol.for key, and a function named version.
  • 1 test for a function named intrinsic.
  • The 3 registry growth tests now check $ark too.
Check Result
pnpm test / pnpm testTyped 1793 passing
pnpm build and pnpm testRepo (V8, integration, attest) passed
full suite with GC before and after each test 1793 passing
pnpm tsc, ESLint, Prettier clean

Not run: pnpm testTsVersions, pnpm bench (type benchmarks).

🤖 Generated with Claude Code

isaacwasserman and others added 2 commits October 1, 2026 13:34
Types that are made at runtime and then dropped (e.g. types with inline
narrow/pipe functions, keys made at runtime, or "this") stayed in memory,
because global caches held them strongly.

- compiled code gets its registered values from a parameter that hides the
  global registry, so registered values are no longer kept in $ark
- nodesByRegisteredId, scope nodesByHash, the string parse cache and the
  default value caches hold their values weakly (WeakValueMap)
- the intersection cache is keyed weakly on both operands
- lazilyResolve remembers its first result, so a synthetic alias always
  resolves to the same node
- names of global registry entries (e.g. intrinsic) are reserved, so a
  function with the same name can't replace them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A WeakRef keeps its target alive until the current job ends. Each parse put
its new context into a WeakRef, so in a synchronous loop each context stayed
in memory until the loop ended (about 190 bytes per type("string") call).
This undid part of the fix in arktypeio#1673 for synchronous code.

- add WeakValueMap.setStrong for short-lived values
- createParseContext holds the context strongly. The parse replaces it with
  its node or releases it, as before
- release the context if its parse throws, and release generic parameter
  contexts after their parse (both stayed in the registry before)
- scope alias contexts stay weak in the registry, because the scope holds them

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

None yet

Development

Successfully merging this pull request may close these issues.

1 participant