Skip to content

Discover unions and interfaces used only by interface fields - #844

Merged
oryan-block merged 3 commits into
masterfrom
bugfix/597
Oct 10, 2026
Merged

oryan-block merged 3 commits into
masterfrom
bugfix/597

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Fixes #597

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

A union that's only used by an interface field can't be built. Take the schema from #597: interface TestInterface { testUnion: TestUnion } is implemented by TestInterfaceImpl, which narrows the field to testUnion: TestUnion1. There's also union TestUnion = TestUnion1 | TestUnion2, and no object type returns TestUnion. Narrowing a field like that is valid GraphQL, but makeExecutableSchema() fails with Expected type 'TestUnion' to be a GraphQLOutputType, but it wasn't! and the scanner logs TestUnion as "defined but can never be accessed". Today you get around it by adding a dummy object field that returns TestUnion, or with includeUnusedTypes(true) plus dictionary entries.

SchemaClassScanner only finds types in two ways: through the fields of object types (by scanning resolver classes), and through implements. It never looks at the field definitions of interfaces. So TestUnion never gets into the scanner's dictionary, and SchemaParser.createInterfaceObject can't find it when it builds TestInterface.testUnion.

Now the discovery loop also walks the field types of every discovered interface, with list and non-null wrappers removed. Any union or interface it hasn't found yet gets added. After that, the existing loops require classes for the union's members and the interface's implementors, the same as for types reached through object fields. I left objects, enums and scalars out on purpose. An implementation can't narrow those, so if the interface has any implementor, they're already found through its class.

The new step only runs once nothing else is left to scan. Running it earlier breaks schemas that build today. Say a union member is only found by scanning another dictionary type, like Holder { image: Image } with Holder in the dictionary. Then the union-member check runs before Holder is scanned and rejects Image. Running it last keeps the discovery order the same for every type that can be reached some other way. The step is also skipped with includeUnusedTypes(true). The unused types loop already picks these unions up one at a time and scans in between. So a member that's only reachable through another member's class is still found there, and checking all members at once would break the current workaround in that case.

I added nine tests to SchemaClassScannerTest. The first six fail on master with the GraphQLOutputType error:

The other three cover the ordering above. Two are for union members and interface implementors that are only found by scanning dictionary types. The third is the includeUnusedTypes workaround with a member that's only reachable through another member's class. These pass on master too and are there so the order doesn't regress.

extend interface is still broken, and I kept it out of this PR. definitionsByName and interfaceDefinitionsByName keep only the last definition with a given name, so the extension replaces the base and the built interface only has the extension's fields. That's an older bug, but it interacts with this change in one spot. With interface EItem { id: ID } and extend interface EItem { content: EContent }, master fails to build because it can't find EContent. Now it builds, but EItem has no id. It needs its own issue. Also, an interface with no implementors at all, whose field returns an object type found nowhere else, still fails in the parser. I don't think that schema is usable anyway.

#382 is the same problem with single-member unions (union Result = Node used as results: [Result!]! on interface SearchResponse). It's already closed as a duplicate of #597. The test uses its schema, plus the Query and enum definitions the issue leaves out.

Behaviour change: schemas with a union or interface used only by interface fields now build. Every member of such a union needs a class, found through resolvers or passed in the dictionary, the same as unions reached through object fields. Without one you now get Object type 'TestUnion2' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'TestUnion2' in the parser's dictionary. instead of the GraphQLOutputType error. It doesn't say which union, but it names the missing type and tells you how to fix it, so I left the message alone. The dummy field workaround in #597 already needed a class for TestUnion2, so the reporter's setup should build without the dummy field now. These unions also stop showing up in the "can never be accessed" warning and in getUnusedDefinitions(). Nothing changes with includeUnusedTypes(true). Apart from the extend interface case above, I don't think any schema that built before behaves differently. The parser always failed on an interface field whose type wasn't discovered.

🤖 Generated with Claude Code

oryan-block and others added 2 commits October 10, 2026 17:16
The nested do/while loops ran each step only once the steps before it had
nothing left, and started over after anything new was found. One loop
with a step per block says the same thing top to bottom, and makes room
for more steps without nesting deeper. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An implementation may narrow an interface field to one member of the
field's union (or one implementor of its interface), which is valid
GraphQL. The scanner only discovers types through object fields and
"implements" clauses, so a union referenced only from an interface field
was never added to the definitions. The schema build then failed in
createInterfaceObject with "Expected type 'TestUnion' to be a
GraphQLOutputType, but it wasn't!", and the union was logged as unused.

Once nothing else is left to scan, the discovery loop now also walks the
field types of every discovered interface and adds any union or
interface that hasn't been found yet. The existing loops then require
classes for the union's members and the interface's implementors, as
they do for types reached through object fields. Running this step last
keeps the discovery order unchanged for every type reachable some other
way, so a union whose members are only found by scanning dictionary
types is still found after those types are scanned. Unions only used by
interface fields no longer need a dummy field or includeUnusedTypes(true)
to be built.

The step is skipped when includeUnusedTypes is on. The unused types loop
already picks these unions and interfaces up one at a time, scanning in
between, so a member that is only reachable through another member's
class is still found there. Checking all members at once would reject
schemas that build today with the includeUnusedTypes workaround.

Fixes #597, the same problem as #382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests for unions and interfaces used only by interface fields made
SchemaClassScannerTest the largest test file, so they now live in
SchemaClassScannerInterfaceFieldTest, like SchemaClassScannerDirectiveTest.

The test for a union on the field of a parent interface is dropped. The
scanner finds that interface through the object's own implements clause,
the same path as the test for interfaces discovered later in the scan.
The ordering tests no longer run a query, since building the schema is
what they check. A new test covers a union that is only found through a
member of another such union, which needs a second pass of the step.

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

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit 404b3e7 into master Oct 10, 2026
6 checks passed
@oryan-block
oryan-block deleted the bugfix/597 branch October 10, 2026 21:27
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.

Union type in an interface-only cannot be used

1 participant