Repository navigation
Fix Federation @external fields picked in object type resolvers extended across multiple modules - #504
Open
BuddhaBing wants to merge 1 commit into
Conversation
…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 detectedLatest commit: f8f531e The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
eddeee888
reviewed
Oct 18, 2026
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; |
Owner
There was a problem hiding this comment.
Hi @BuddhaBing, this bit is going to be hard to maintain because:
- it's rebuilding the expected
nodethatapolloFederationneeds, with transformed values i.e. it's leakingApolloFederation's logic here findFieldNodesToGeneratealready ran in the mainresolver-typesi.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:
- add some more metadata about which fields are available into this block
- then use said metadata (accessible by
ctx.config.generatedTypesFileMeta) to filter out thefieldsToPicki.e.fieldsToPickcontains 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #503
Why
With
typesPluginsConfig.federation: true, when an object type is extended across multiple modules, each module's resolver is typed asPick<XResolvers, ...>of the fields declared in that module.fieldsToPickwas built from every field on the type, including Federation@externalfields.typescript-resolversdoesn't generate resolver types for those, unless there's a@providespath to them.The picked keys therefore don't match the resolvers type:
useIndexSignature: true, the extra keys become required properties (resolved via the index signature), so the resolver object fails to typecheck.Pickfails because the keys aren't inkeyof XResolvers.Fixing the type by hand doesn't work because the static analysis rewrites the resolver type on every run. The common
@requirespattern (each module declares the@externalfields it needs) is the usual way to hit this. See #503 for a reproduction.What
findFieldNamesToGenerate(new): returns the names of the fieldstypescript-resolversgenerates resolver types for. It usesApolloFederation.findFieldNodesToGeneratefrom@graphql-codegen/plugin-helpers, the same functiontypescript-resolversuses.parseGraphQLSchema/handleObjectType: skips fields that aren't in that set when grouping fields by module, so they're never added tofieldsToPick.preset: passesBoolean(typesPluginsConfig.federation)intoparseGraphQLSchema. When federation is disabled,ApolloFederationreturns every field, so behaviour is unchanged.Matching
typescript-resolversmeans:@externalwithout a@providespath@externalwith a@providespath@externalobject type (definition or any extension), without@providesTests
Unit:
findFieldNamesToGenerate.spec.tscovers federation disabled,@externalwith and without@provides, and an@externalobject type extension.E2E: added
subgraph-shippingtotest-federation.Productis split across two modules, with@external+@requiresin both and an@externalfield that is@provides-ed fromQuery. Generated output:part-1:Pick<ProductResolvers, 'shippingEstimate'|'upc'|'__resolveReference'>part-2:Pick<ProductResolvers, 'name'|'tax'|'__resolveReference'>Without the fix,
part-1also picks'weight'andpart-2also picks'price'.nx test typescript-resolver-filesandnx e2e typescript-resolver-files-e2eboth pass. No other e2e output changes.Changeset added (patch).
Potential negative implications
@externalfields to satisfy the required keys) will start failing excess-property checks, because those keys are no longer in thePick. That's the correct outcome, since those resolvers would never be called, but it's still a visible change on upgrade.@externalfields, that module no longer counts as one of the type's modules. If that leaves a single module, it gets the fullXResolverstype and noPick, 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.ApolloFederationinternals. We inherit its known limitations, e.g.@providesonly tracks top-level field names (there's aFIXMEfor nested selection sets inplugin-helpers). The upside is thatfieldsToPickand the generated resolvers type can't drift apart, since both use the same code.ApolloFederationinstance per run, which builds a@providesmap 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
ApolloFederationinstead of reimplementing the rules. A simpler alternative is to skip any field with an@externaldirective in the preset. I didn't do that because it gets@providesand@externalobject types wrong, and it would apply even when federation is off (e.g. a non-federated schema with its own@externaldirective). Reusing the same function astypescript-resolverskeeps both in sync. The downside is the coupling described below.findFieldNodesToGeneratewith a synthesised node and a cast. It's typed as taking anObjectTypeDefinitionNode, but at runtime it readsnode.fields[].node, the visitor-transformed shapetypescript-resolverspasses in.findFieldNamesToGeneratebuilds that shape (merging directives from the type definition and allextensionASTNodes, so@externalon any extension is detected) and casts viaas unknown as ObjectTypeDefinitionNode, with a comment explaining why. This relies on an internal detail ofplugin-helpers. If you'd prefer not to, alternatives are:@providesparsing), orparseGraphQLSchemaruns before the types file is generated.handleObjectType, not inhandleGraphQLObjectType. Filtering before grouping by module means skipped fields don't create a module entry (see the module-grouping point above), andfieldsToPickstays in one place. Filtering later, atPickgeneration, would keep modules made up only of external fields, but they'd get resolver files with an emptyPick.ApolloFederationinstance perparseGraphQLSchemacall, passed down tohandleObjectType. This avoids rebuilding the@providesmap for every object type.vite.config.ts:server.deps.inline: ['@graphql-codegen/plugin-helpers']. The new spec is the first to importgraphqldirectly. Without inlining, Vitest loadsplugin-helpersthrough Node and the spec'sgraphqlthrough Vite. That gives twographqlinstances and fails withCannot use GraphQLObjectType "Query" from another module or realm. Inlining only affects tests. An alternative is to avoid constructingApolloFederationin the unit test and rely on the e2e test alone.test-federationinstead of a new e2e suite, so it reuses the existing federation setup with noproject.jsonchanges.🤖 Generated with Claude Code