Skip to content

isProvablyObjectShapedRoot does not recognize $defs/$ref to typeless object-shaped schemas (e.g. named discriminatedUnion) #3094

Open

Description

@shafiktanbir

In commit 5820f796 (#3081), isProvablyObjectShapedRoot in packages/core-internal/src/util/standardSchema.ts was updated to follow a same-document $ref into $defs / definitions, checking target?.type === 'object'.

When a $defs / definitions entry is a typeless object-shaped schema like z.discriminatedUnion('kind', [z.object(...), z.object(...)]).meta({ id: 'Result' }) (or a union/intersection of objects, or an object schema with properties that omits type: 'object'), target.type === 'object' returns false and wraps structuredContent in { result: ... }, even though the identical schema written inline without .meta({ id }) returns true.

Should isProvablyObjectShapedRoot also check isProvablyObjectShapedRoot(target, {}) when resolving a $ref target?

ss
Image

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 25, 2026
  2. Ben-Home commented on Oct 28, 2026

    @Ben-Home

    The answer to your question is yes, and the reason is a naming invariant rather than a preference: the same schema must get the same answer whether or not it carries an id.

    Right now the two paths disagree. The inline call runs the full predicate, while the $ref branch only tests target.type === 'object', so any referenced schema that is object-shaped without that keyword flips the result. z.discriminatedUnion(...).meta({ id }) is the loudest case, but a referenced z.object() with properties and no type, and any allOf composition of objects, land on the same side of the branch. That is exactly the class the predicate exists to resolve, so recursing on the target with the same function is the consistent fix.

    Two things to get right when you do it. Recursing through $defs needs a visited set keyed on the pointer, because a self-referential schema will otherwise loop, and the $ref cycle case is common in generated schemas. And since the visible effect of the boolean is the { result: ... } wrapper, the regression test should assert on the shape of structuredContent rather than on the helper's return value: build one schema inline and the identical schema named through $defs, then assert the two calls produce equal structured content. That test fails today, passes after the change, and stays honest if the predicate is refactored again later.

    Worth deciding explicitly whether a referenced non-object, such as a named primitive, should stay false, since the same recursion then needs a deliberate terminal case rather than an implicit fallthrough. Stating it in the doc comment prevents the next reader from assuming the $ref branch mirrors the inline path, which is the assumption that produced this report.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions