Skip to content

refactor: migrate Serializable type to Zod schema and fix circular reference handling#12865

Merged
kangfenmao merged 5 commits intomainfrom
refactor/serialize-zod-schema
Feb 12, 2026
Merged

refactor: migrate Serializable type to Zod schema and fix circular reference handling#12865
kangfenmao merged 5 commits intomainfrom
refactor/serialize-zod-schema

Conversation

@EurFelux
Copy link
Copy Markdown
Collaborator

@EurFelux EurFelux commented Feb 10, 2026

What this PR does

Before this PR:

  • Serializable type and isSerializable function were maintained separately
  • SerializableValue used any workaround (with FIXME comment about TS2589)
  • No runtime validation schema available for consumers
  • error.ts directly assigned error.response without serialization, causing type errors with Date objects
  • isSerializable incorrectly returned true for circular references, despite JSON.stringify throwing on them

After this PR:

  • Unified type definition using Zod schema backed by the existing isSerializable type guard via z.custom(), ensuring consistent validation behavior
  • Added SerializableSchema export for use by other modules
  • Removed unused helper functions (assertSerializable, tryParseSerializable) to keep exports minimal
  • Fixed error.ts to properly serialize response field using safeSerialize()
  • Fixed isSerializable to correctly reject circular references
  • Improved isSerializable return type to value is Serializable for better type narrowing
  • Added comprehensive unit tests for isSerializable and SerializableSchema
  • Removed misleading FIXME comment about TS2589

Fixes #

Why we need it and why it was done in this way

The following tradeoffs were made:

  • Used z.custom(isSerializable) instead of building a recursive Zod schema with z.lazy(), to guarantee the Zod schema and the hand-written type guard share identical validation logic (e.g. circular reference handling, prototype chain checks, built-in object rejection)
  • Kept existing isSerializable() implementation as the single source of truth for runtime checks

The following alternatives were considered:

  • Building a standalone recursive Zod schema with z.lazy() + z.union() + z.record(), but this had subtle behavioral differences from isSerializable() (e.g. z.record() does not check prototype chains or reject Date/class instances)
  • Using z.infer to derive type from schema, but z.lazy() requires explicit type annotation to avoid any inference
  • Using Zod 4's built-in z.json() — this was evaluated as a potential replacement for the entire hand-written isSerializable + z.custom() approach. z.json() validates the same recursive union (string | number | boolean | null | JsonValue[] | Record<string, JsonValue>), which is structurally identical to our Serializable type. However, it was not adopted for the following reasons:
    1. Circular reference safety: z.json() is built on z.lazy(), which recurses without cycle detection. Passing a circular reference causes a stack overflow instead of returning a validation failure. Our isSerializable() uses a seen Set to detect cycles and safely returns false. This is critical because utils/serialize.ts calls isSerializable() on arbitrary values of unknown provenance.
    2. Performance: The hand-written check uses direct typeof comparisons and a single recursive walk. z.json() uses z.union([...]) which must attempt each branch in order, adding overhead for deep/large objects.
    3. Explicit built-in object rejection: isSerializable() explicitly rejects Date, RegExp, Map, Set, Error, File, and Blob with clear instanceof checks and a prototype chain guard, making the rejection reasons self-documenting. z.json() rejects them implicitly (they don't match any union branch), which is correct but less obvious to readers.

Breaking changes

None. All existing exports remain unchanged:

  • Serializable type (same signature)
  • isSerializable function (same behavior for all valid inputs; circular references now correctly return false instead of true — this is a bug fix, as circular references are not JSON-serializable and tryLenientSerialize already expects them to be rejected)

Special notes for your reviewer

  • The error.ts change (line 196) fixes a type error that surfaced after making Serializable stricter — error.response is LanguageModelResponseMetadata which contains Date objects, so it needs safeSerialize() instead of direct assignment.
  • SerializableSchema uses z.custom() to delegate all validation to isSerializable, so the two are guaranteed to be consistent.
  • The circular reference fix is a bug fix: isSerializable previously returned true for circular references to "avoid infinite recursion", but this was incorrect — circular references cause JSON.stringify to throw, so they are by definition not serializable. The tryLenientSerialize design also assumes circular references are not serializable.

Checklist

Release note

NONE

🤖 Generated with Claude Code

Co-Authored-By: Claude [email protected]

EurFelux and others added 5 commits February 11, 2026 01:54
- Replace manual type definition with Zod schema using z.lazy()
- Add SerializableSchema export for runtime validation
- Add assertSerializable() and tryParseSerializable() helpers
- Fix error.ts to use safeSerialize() for response field
- Remove FIXME comment about TS2589 recursion

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
Circular references cause JSON.stringify to throw, so returning true
was misleading. Now correctly returns false.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@EurFelux EurFelux changed the title refactor: migrate Serializable type to Zod schema refactor: migrate Serializable type to Zod schema; fix: reject circular references Feb 10, 2026
@EurFelux EurFelux changed the title refactor: migrate Serializable type to Zod schema; fix: reject circular references refactor: migrate Serializable type to Zod schema and fix circular reference handling Feb 10, 2026
@kangfenmao kangfenmao merged commit 6cc1576 into main Feb 12, 2026
9 checks passed
@kangfenmao kangfenmao deleted the refactor/serialize-zod-schema branch February 12, 2026 10:57
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.

3 participants