Skip to content

Fix Federation @external fields picked in object type resolvers extended across multiple modules - #504

Open
BuddhaBing wants to merge 1 commit into
eddeee888:masterfrom
BuddhaBing:fix/federation-external-fields-to-pick
Open

BuddhaBing wants to merge 1 commit into
eddeee888:masterfrom
BuddhaBing:fix/federation-external-fields-to-pick

Conversation

@BuddhaBing

Copy link
Copy Markdown

Closes #503

Why

With typesPluginsConfig.federation: true, when an object type is extended across multiple modules, each module's resolver is typed as Pick<XResolvers, ...> of the fields declared in that module. fieldsToPick was built from every field on the type, including Federation @external fields. typescript-resolvers doesn't generate resolver types for those, unless there's a @provides path to them.

The picked keys therefore don't match the resolvers type:

  • With useIndexSignature: true, the extra keys become required properties (resolved via the index signature), so the resolver object fails to typecheck.
  • Without it, Pick fails because the keys aren't in keyof XResolvers.

Fixing the type by hand doesn't work because the static analysis rewrites the resolver type on every run. The common @requires pattern (each module declares the @external fields it needs) is the usual way to hit this. See #503 for a reproduction.

What

  • findFieldNamesToGenerate (new): returns the names of the fields typescript-resolvers generates resolver types for. It uses ApolloFederation.findFieldNodesToGenerate from @graphql-codegen/plugin-helpers, the same function typescript-resolvers uses.
  • parseGraphQLSchema / handleObjectType: skips fields that aren't in that set when grouping fields by module, so they're never added to fieldsToPick.
  • preset: passes Boolean(typesPluginsConfig.federation) into parseGraphQLSchema. When federation is disabled, ApolloFederation returns every field, so behaviour is unchanged.

Matching typescript-resolvers means:

Field Picked?
@external without a @provides path No
@external with a @provides path Yes
On an @external object type (definition or any extension), without @provides No
Any field when federation is disabled Yes

Tests

  • Unit: findFieldNamesToGenerate.spec.ts covers federation disabled, @external with and without @provides, and an @external object type extension.

  • E2E: added subgraph-shipping to test-federation. Product is split across two modules, with @external + @requires in both and an @external field that is @provides-ed from Query. Generated output:

    • part-1: Pick<ProductResolvers, 'shippingEstimate'|'upc'|'__resolveReference'>
    • part-2: Pick<ProductResolvers, 'name'|'tax'|'__resolveReference'>

    Without the fix, part-1 also picks 'weight' and part-2 also picks 'price'.

  • nx test typescript-resolver-files and nx e2e typescript-resolver-files-e2e both pass. No other e2e output changes.

  • Changeset added (patch).

Potential negative implications

  • Some existing user code may now get type errors. For users already hitting this bug, any code that worked around it (e.g. implementing resolvers for @external fields to satisfy the required keys) will start failing excess-property checks, because those keys are no longer in the Pick. That's the correct outcome, since those resolvers would never be called, but it's still a visible change on upgrade.
  • Module grouping can change. If a module's only fields on a type are skipped @external fields, that module no longer counts as one of the type's modules. If that leaves a single module, it gets the full XResolvers type and no Pick, and no resolver file is generated for the skipped module. I think that's the right result, but it does change which files are generated.
  • Behaviour now depends on ApolloFederation internals. We inherit its known limitations, e.g. @provides only tracks top-level field names (there's a FIXME for nested selection sets in plugin-helpers). The upside is that fieldsToPick and the generated resolvers type can't drift apart, since both use the same code.
  • Small extra cost. One ApolloFederation instance per run, which builds a @provides map by walking all object type fields once. This should be negligible next to the rest of the preset.

Decisions where a different pattern may be preferred

  1. Reusing ApolloFederation instead of reimplementing the rules. A simpler alternative is to skip any field with an @external directive in the preset. I didn't do that because it gets @provides and @external object types wrong, and it would apply even when federation is off (e.g. a non-federated schema with its own @external directive). Reusing the same function as typescript-resolvers keeps both in sync. The downside is the coupling described below.
  2. Calling findFieldNodesToGenerate with a synthesised node and a cast. It's typed as taking an ObjectTypeDefinitionNode, but at runtime it reads node.fields[].node, the visitor-transformed shape typescript-resolvers passes in. findFieldNamesToGenerate builds that shape (merging directives from the type definition and all extensionASTNodes, so @external on any extension is detected) and casts via as unknown as ObjectTypeDefinitionNode, with a comment explaining why. This relies on an internal detail of plugin-helpers. If you'd prefer not to, alternatives are:
    • reimplement the same rules locally (duplicating @provides parsing), or
    • read the field names from the generated resolvers type via ts-morph, which the preset already loads for static analysis. That would be the most exact, but it means restructuring, since parseGraphQLSchema runs before the types file is generated.
  3. Filtering inside handleObjectType, not in handleGraphQLObjectType. Filtering before grouping by module means skipped fields don't create a module entry (see the module-grouping point above), and fieldsToPick stays in one place. Filtering later, at Pick generation, would keep modules made up only of external fields, but they'd get resolver files with an empty Pick.
  4. One ApolloFederation instance per parseGraphQLSchema call, passed down to handleObjectType. This avoids rebuilding the @provides map for every object type.
  5. vite.config.ts: server.deps.inline: ['@graphql-codegen/plugin-helpers']. The new spec is the first to import graphql directly. Without inlining, Vitest loads plugin-helpers through Node and the spec's graphql through Vite. That gives two graphql instances and fails with Cannot use GraphQLObjectType "Query" from another module or realm. Inlining only affects tests. An alternative is to avoid constructing ApolloFederation in the unit test and rely on the e2e test alone.
  6. E2E coverage as a new subgraph in test-federation instead of a new e2e suite, so it reuses the existing federation setup with no project.json changes.

🤖 Generated with Claude Code

…solvers

When an object type is extended across multiple modules, each module's
resolver is typed as `Pick<XResolvers, ...>` of the fields declared in that
module. This included `@external` fields, which `typescript-resolvers` does
not generate resolver types for, causing type errors.

Filter fields to pick using the same `ApolloFederation` logic as
`typescript-resolvers` when `typesPluginsConfig.federation` is enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f8f531e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@eddeee888/gcg-typescript-resolver-files Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment on lines +25 to +33
const node = {
name: { value: namedType.name },
directives: [namedType.astNode, ...namedType.extensionASTNodes].flatMap(
(typeNode) => typeNode?.directives || []
),
fields: Object.values(namedType.getFields()).map((field) => ({
node: field.astNode || { name: { value: field.name } },
})),
} as unknown as ObjectTypeDefinitionNode;

@eddeee888 eddeee888 Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @BuddhaBing, this bit is going to be hard to maintain because:

  • it's rebuilding the expected node that apolloFederation needs, with transformed values i.e. it's leaking ApolloFederation's logic here
  • findFieldNodesToGenerate already ran in the main resolver-types i.e. this'd be doubling up the work

Currently, we are passing the typescript-resolvers run meta via meta which may look like this:

userDefined: {
      Product: {
        name: 'ProductResolvers',
        hasIsTypeOf: false,
        federation: { hasResolveReference: true }
      },
      Query: { name: 'QueryResolvers', hasIsTypeOf: false }
    }

Here's how I think we could tackle this problem hollistically:

  1. add some more metadata about which fields are available into this block
  2. then use said metadata (accessible by ctx.config.generatedTypesFileMeta) to filter out the fieldsToPick i.e. fieldsToPick contains all fields of a module, if it wasn't generated in the metadata, do not show it)

Let me know what you think 🙂
This'd be a bit longer but it keeps the existing principles. I could help create PRs in either repo to move things along if you'd like. Let me know

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.

[BUG] Federation @external fields are picked in object type resolvers extended across multiple modules

2 participants