From c06fd0e050765e68a1ef1595f8f27d9cf36f9c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:29:24 +0000 Subject: [PATCH] fix: accept shared objects in boolean object guards The boolean object guard now removes each object from its reference map when the branch finishes, so only the active recursion path is tracked. An object shared by sibling properties is no longer reported as a circular reference, matching validate() and validateAll(). Closes #449 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019BJENnU8SXcDH2Tcx5EWE4 --- src/typeChecker.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++ src/typeChecker.ts | 12 ++++++--- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/typeChecker.test.ts b/src/typeChecker.test.ts index b168d3fb..3b2b109d 100644 --- a/src/typeChecker.test.ts +++ b/src/typeChecker.test.ts @@ -9,6 +9,7 @@ import { typeCheckerConfig, } from "./typeChecker.ts"; import type { Nominal, TypeChecker } from "./types.ts"; +import { validate, validateAll } from "./validate.ts"; test("Should able to define tree structures", () => { typeCheckerConfig.resetNoNameTypeCount(); @@ -69,6 +70,64 @@ test("Should detect circular references.", () => { assert.throws(() => isT(obj), /^Error: CircularReference:/); }); +const assertSharedObjectAccepted = ( + isT: TypeChecker, + input: unknown, +): void => { + assert.equal(isT(input), true, `${isT}`); + assert.equal(validate(input, isT).ok, true, `${isT}`); + assert.equal(validateAll(input, isT).ok, true, `${isT}`); +}; + +test("Should accept objects shared between sibling properties.", () => { + const shared = { name: "Ada" }; + assertSharedObjectAccepted( + typeChecker({ left: { name: isString }, right: { name: isString } }), + { left: shared, right: shared }, + ); + assertSharedObjectAccepted( + isObjectWith({ left: { name: isString }, right: { name: isString } }), + { left: shared, right: shared }, + ); + assertSharedObjectAccepted( + typeChecker({ + left: { name: isString }, + right: isObjectWith({ name: /^A/ }), + }), + { left: shared, right: shared }, + ); + assertSharedObjectAccepted( + typeChecker({ + pair: { left: { name: isString } }, + right: { name: isString }, + }), + { pair: { left: shared }, right: shared }, + ); +}); + +test("Should detect circular references reached through a shared object.", () => { + interface Node { + name: string; + next: Node; + } + const isNode: TypeChecker = typeChecker( + { + name: isString, + get next() { + return isNode; + }, + }, + "Node", + ); + const isPair = typeChecker({ left: isNode, right: isNode }); + const shared = { name: "shared" } as Node; + shared.next = shared; + assert.throws( + () => isPair({ left: shared, right: shared }), + /^Error: CircularReference:/, + ); +}); + test("Should test type guards and object definitions.", () => { const isText = typeChecker( (input: unknown): input is string => typeof input === "string", diff --git a/src/typeChecker.ts b/src/typeChecker.ts index 35f67e97..2a67fe6e 100644 --- a/src/typeChecker.ts +++ b/src/typeChecker.ts @@ -536,10 +536,16 @@ const objectFactoryProps = ( if (exact && hasUnknownKey(v)) { return false; } + // refs holds only the active recursion path, so an object shared by + // sibling properties is not mistaken for a circular reference. refs.set(v, typeName); - return properties().every(([k, is]) => - runGuard(is, getProperty(v, k), refs), - ); + try { + return properties().every(([k, is]) => + runGuard(is, getProperty(v, k), refs), + ); + } finally { + refs.delete(v); + } }; return { typeGuard,