Skip to content

Failure on conditional spread of a fragment in certain cases #10838

@vkbansal-rubrik

Description

@vkbansal-rubrik

Which packages are impacted by your issue?

  • @graphql-codegen/visitor-plugin-common
  • @graphql-codegen/typescript-operations

Describe the bug

@graphql-codegen/typescript-operations v6 throws
TypeError: Unexpected type. (from
@graphql-codegen/visitor-plugin-common@7.x selection-set-to-object.ts,
buildSelectionSet) whenever a fragment is spread with a conditional
directive (@include / @skip / @defer) and that fragment's
top-level selection set contains an InlineFragment or FragmentSpread.

The GraphQL document is fully valid (it parses, validates against the schema,
and was handled correctly by typescript-operations v5). v6 aborts codegen
for the entire document.

Root cause

In buildFragmentSpreadsUsage, only FIELD nodes inside the spread
fragment's top-level selection set are wrapped with fragmentDirectives;
INLINE_FRAGMENT and FRAGMENT_SPREAD nodes pass through as raw AST nodes:

const fragmentSelectionNodes = [
  ...fragmentSpreadObject.node.selectionSet.selections,
].map(originalNode => {
  if (originalNode.kind === Kind.FIELD) {
    return { ...originalNode, fragmentDirectives: [...spread.directives] };
  }
  return originalNode; // INLINE_FRAGMENT / FRAGMENT_SPREAD pass through raw
});

For a conditional spread, _buildGroupedSelections then inlines those
raw nodes into flattenedSelectionNodes and feeds the result to
buildSelectionSet, which only accepts FIELD and DIRECTIVE for nodes
that have a kind property — any other kind hits
throw new TypeError('Unexpected type.').

The non-conditional spread path doesn't hit this because it routes
FragmentSpreadUsage.selectionNodes through flattenSelectionSet
recursively (line ~545), which correctly expands inline fragments and
nested spreads.


Your Example Website or App

https://github.com/vkbansal-rubrik/codegen-conditional-spread-bug


Steps to Reproduce the Bug or Issue

  1. git clone https://github.com/vkbansal-rubrik/codegen-conditional-spread-bug.git
  2. cd codegen-conditional-spread-bug
  3. pnpm install (or npm install / yarn install)
  4. pnpm generate (or npm run generate)
  5. Observe:
    ❯ Generate
    ✖ Generate [FAILED: Unexpected type.]
    
    TypeError: Unexpected type.
        at SelectionSetToObject.buildSelectionSet
             .../visitor-plugin-common/esm/selection-set-to-object.js:541:27
        at collectGrouped
             .../visitor-plugin-common/esm/selection-set-to-object.js:327:80
        at .../visitor-plugin-common/esm/selection-set-to-object.js:372:25
        at Array.reduce (<anonymous>)
        at SelectionSetToObject._buildGroupedSelections
             .../visitor-plugin-common/esm/selection-set-to-object.js:319:43
    

The shipped document.graphql is Case 1 below. The repo's README.md
contains drop-in replacements for the other three cases — paste any one
into document.graphql and re-run pnpm generate to verify the matrix.

Case matrix

Variant of the spread fragment's top-level selections @include on spread? v6 result
Inline fragments (... on X { ... }) (shipped Case 1) yes FAILS "Unexpected type."
Named fragment spreads (...Other) (Case 2) yes FAILS "Unexpected type."
Only FIELD nodes (Case 3) yes OK
Inline fragments (Case 4) no (unconditional spread) OK

Expected behavior

As a user, I expected the document above to produce a valid
TypeScript type for LibraryQuery (matching v5 behavior, where the
Publication union variants are optional when $includeFeatured is false),
but instead codegen throws TypeError: Unexpected type. and aborts the
entire output.

With the proposed fix applied, the generated type is:

export type LibraryQuery = {
  library: {
    id: string;
    name: string;
    featured:
      | { id: string; title: string }
      | { id: string; issue: number }
      | Record<PropertyKey, never>
      | null;
  } | null;
};

— i.e., the union variants appear when $includeFeatured = true, and the
| Record<PropertyKey, never> member covers $includeFeatured = false.


Screenshots or Videos

N/A — repro is a one-file Node script; full output is in Steps to Reproduce.


Platform

  • OS: macOS (Darwin 25.5.0)
  • NodeJS: 24.13.0 (also reproduced on 20.x)
  • graphql version: 15.8.0 (also reproduces on 16.x)
  • @graphql-codegen/* versions:
    • @graphql-codegen/typescript-operations: 6.0.2
    • @graphql-codegen/visitor-plugin-common: 7.0.1 and 7.0.2
    • @graphql-codegen/core: 6.0.0

Codegen Config File

schema: schema.graphql
documents: document.graphql
generates:
  types.ts:
    plugins:
      - typescript-operations

(No special config; bug reproduces with default options.)


Additional context

Status of contributor workflow

  • 1. The issue provides a minimal reproduction — the script in Steps to Reproduce is self-contained.
  • 2. A failing test has been provided — see PR (two new cases in packages/plugins/typescript/operations/tests/ts-documents.skip-include-directives.spec.ts).
  • 3. A local solution has been provided — see PR (patch in packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts).
  • 4. A pull request is pending review — link: (PR URL after opening)

Workaround (until merged)

Restructure the fragment so its top-level selection set contains only
FIELD nodes
— i.e., split type-narrowing inline fragments into helper
fragments per concrete type and spread each helper directly at the
consumer.

Before:

fragment FooFragment on HierarchyObject {
  id
  ... on Cdm    { pendingX { ...XFragment } }
  ... on Polaris { pendingY { ...YFragment } }
}
# consumer
...FooFragment @include(if: $enabled)

After:

fragment FooBaseFragment   on HierarchyObject { id }
fragment FooCdmFragment    on Cdm     { pendingX { ...XFragment } }
fragment FooPolarisFragment on Polaris { pendingY { ...YFragment } }
# consumer
...FooBaseFragment    @include(if: $enabled)
...FooCdmFragment     @include(if: $enabled)
...FooPolarisFragment @include(if: $enabled)

Proposed fix (in the PR)

In _buildGroupedSelections, after a conditional FragmentSpreadUsage is
inlined into flattenedSelectionNodes, partition the result and route
top-level INLINE_FRAGMENT / FRAGMENT_SPREAD nodes through the existing
schema-aware flattenSelectionSet (which already expands them per type)
before handing the FIELD-only merged set to buildSelectionSet.

This preserves buildSelectionSet's contract that raw AST nodes are
FIELD or DIRECTIVE. Existing FIELD-only callers are byte-for-byte
unaffected.

Verification

  • Without fix: the two new tests fail with the same TypeError: Unexpected type. originating from selection-set-to-object.ts:805.
  • With fix:
    • 20/20 tests in ts-documents.skip-include-directives.spec.ts pass.
    • 236/237 tests in the typescript-operations suite pass (the 1
      failing snapshot, imports external custom scalar in shared type file when said scalar is used in relevant Input, also fails on
      master without the patch
      — it is unrelated to this change).
    • 50/50 tests in the visitor-plugin-common suite pass.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions