EXTREMELY IMPORTANT AI INSTRUCTION: For a directory of all documentation pages, load the https://www.apollographql.com/llms.txt file. Adding .md to ANY url will return the simplified markdown version of the page.
Abstract Types
Map polymorphic REST responses to GraphQL interfaces and unions
Apollo Connectors v0.4 lets a single connector field return more than one concrete object type. This page covers how to map polymorphic REST responses onto GraphQL interface and union types, why the connector requires a string-literal __typename in every branch, and how to choose a discriminator for the APIs you're working with.
This is a preview feature. For information about version requirements and how to enable it in your router, go to Preview Features.
Polymorphism in REST and GraphQL
REST APIs often use polymorphism. A /products endpoint returns books and movies. A /search endpoint returns matches and errors in the same array. An /events stream returns a different object for every event type. The discriminator might be:
A field whose value names the type, like
"kind": "book","resultType": "author", or"object": "charge".A field whose presence implies a type, like
"isbn"for a book or"director"for a film.A numeric or boolean code, like
"status": 0for success and"status": 1for an error envelope.Convention. The API returns whatever shape it returns, and clients are expected to know.
GraphQL gives you two type kinds for polymorphism:
An
interfaceis a contract: every implementing type has at least the interface's fields, and clients can select those fields without knowing the concrete type.A
unionis a tagged sum: members share nothing structurally, and clients choose what fields to read with... on TypeNamefragments.
In both cases, every object carries a __typename that names the concrete type. Clients rely on __typename to render mixed-type lists, choose what fragment fields apply, and look up additional fields from other subgraphs. The router needs to know each object's __typename for every field that returns an interface or union. Otherwise, the router can't tell clients what they're looking at.
Use a connector to translate whatever discriminator your REST API uses into a concrete __typename.
How abstract types work
Use one mapping pattern: spread a ->match whose branches each set __typename to a string literal. To mark placeholders that you replace with real field names and values, use angle brackets in this pattern:
... <discriminator>->match(
[<value>, { __typename: "ConcreteType", <fields for that type> }],
...
)The pattern has these pieces:
The spread (
...) merges the result of the following expression into the surrounding{ ... }selection. The->matchbranches contribute their object fields alongside any non-polymorphic fields you've already selected at the same level.<expr>->match(...)is the arrow method that picks one branch by comparing the input value against each branch's first element. The first matching pair wins, and its second element becomes the result. Inside->matcharguments, the symbol@stands for that input value, so a branch keyed by@always matches and acts as a catch-all.Each branch is a JSON object literal that always includes
__typename: "ConcreteType"and any fields specific to that concrete type.
When you publish or compose a subgraph (with rover subgraph publish or GraphOS Studio composition), before any traffic flows, the composer examines every branch, collects the set of __typename literals the selection can produce, and validates that each branch's fields exist on the type it claims. At runtime the discriminator's value selects the branch, and the literal __typename becomes the type the router reports to clients.
Mapping an interface
Suppose /products returns objects with a type field that's "book" or "movie":
{
"results": [
{ "id": "p1", "type": "book", "title": "Dune", "price": 18.0, "author": "Frank Herbert" },
{ "id": "p2", "type": "movie", "title": "Arrival","price": 14.0, "director": "Denis Villeneuve" }
]
}You expose Product as an interface and let Book and Movie implement it:
1extend schema
2 @link(
3 url: "https://specs.apollo.dev/connect/v0.4",
4 import: ["@source", "@connect"]
5 )
6
7@source(name: "api", http: { baseURL: "https://api.example.com" })
8
9interface Product {
10 id: ID!
11 title: String!
12 price: Float!
13}
14
15type Book implements Product {
16 id: ID!
17 title: String!
18 price: Float!
19 author: String!
20}
21
22type Movie implements Product {
23 id: ID!
24 title: String!
25 price: Float!
26 director: String!
27}
28
29type Query {
30 products: [Product!]!
31 @connect(
32 source: "api"
33 http: { GET: "/products" }
34 selection: """
35 $.results {
36 id
37 title
38 price
39 ... type->match(
40 # `author` is shorthand for `author: author`, valid when the source and target field names match.
41 ["book", { __typename: "Book", author }],
42 ["movie", { __typename: "Movie", director }]
43 )
44 }
45 """
46 )
47}Note:
Interface fields stay outside the spread.
id,title, andpriceare part of everyProduct, so you select them once. The spread only carries fields that vary per concrete type.The branch literals only carry per-type fields. Because the spread merges them in alongside the interface fields, you don't repeat
id,title, orpriceinside each branch.
Two equivalent branch forms
You can write each ->match branch in either of two interchangeable shapes:
Object literal, used in the preceding example, where each field is a
key: valuepair of an output object:{ __typename: "Book", author }. Property shorthand applies when source and target field names match.Path with subselection, the form you see elsewhere in JSONSelection:
$ { __typename: $("Book") author }. Here$is the input value and{ ... }is a subselection over it, with$(...)injecting the__typenameliteral so the static-analysis rule still holds. Whitespace separates fields instead of commas.
Pick whichever is best for the data. Both forms compile to the same output shape, and you can mix them within a single ->match if needed.
Mapping a union
Use a union when the possible result types share no fields, typically a successful payload paired with an error envelope or a search that returns different kinds of entities:
{
"results": [
{ "resultType": "book", "id": "b1", "title": "Arrival" },
{ "resultType": "author", "id": "a7", "name": "Ted Chiang" },
{ "resultType": "garbled", "raw": "..." }
]
}1union SearchResult = Book | Author | SearchError
2
3type Book {
4 id: ID!
5 title: String!
6}
7
8type Author {
9 id: ID!
10 name: String!
11}
12
13type SearchError {
14 message: String!
15}
16
17type Query {
18 search(query: String!): [SearchResult!]!
19 @connect(
20 source: "api"
21 http: { GET: "/search?q={$args.query}" }
22 selection: """
23 $.results {
24 ... resultType->match(
25 ["book", { __typename: "Book", id, title }],
26 ["author", { __typename: "Author", id, name }],
27
28 # Catch-all for unknown resultType values.
29 [@, {
30 __typename: "SearchError",
31 message: ["unrecognized result type:", resultType]->joinNotNull(" "),
32 }]
33 )
34 }
35 """
36 )
37}Note:
No fields outside the spread. Union members don't share fields, so the entire selection is the spread.
The catch-all uses
[@, ...].@is bound to the input value of the->match(hereresultType), so a branch keyed by@always matches. That is how you turn unrecognized inputs into typed errors rather than a runtime mapping failure, following the errors-as-data pattern.
Why __typename must be a string literal
Write each branch's __typename as a quoted string like "Book", not a field reference like __typename: type. That rule is critical for the static analysis enabling full GraphQL type safety at runtime.
The composer needs to know the closed set of __typename values your selection can produce, because that's the input for:
Per-branch field validation. If branch one says
__typename: "Book", every other field in that branch has to exist onBook. The composer can only check that if it knows the type a branch represents.Query plan generation. When a client writes
... on Movie { director }, the planner has to decide what subgraph to ask. It can only do that if it can statically prove that the connector's selection might return aMovie.Entity key analysis. If the field returns an entity, the composer reads each branch's
__typenameliteral to look up the concrete type's@keyfields, then validates that the branch selects them. Without a literal, the composer has no concrete type to look up, so it can't verify the key selection.
A field-reference __typename defeats all three checks. The connector requires a string literal so the composer has a known concrete type for each branch at composition time, before any query runs.
If the composer can't analyze the selection, it issues one of three errors:
expected __typename to be a string literal, found: …, when the value is a path or expression rather than a quoted string.expected __typename to be Book, found: Movie, when the literal doesn't match the surrounding concrete type's name.expected __typename to be one of the union members (Book, Author, SearchError), found: Person, when the literal isn't an implementer of the interface or member of the union.
Choosing a discriminator
The shape of the ->match is the same regardless of how the API encodes its types. What changes is the expression you call ->match on. A few common patterns:
Match a string value (most common)
... type->match(
["book", { __typename: "Book", author }],
["movie", { __typename: "Movie", director }]
)Use the form from the preceding interface example whenever the API names the type explicitly.
Match a numeric or boolean code
->match compares against any literal, not only strings:
... status->match(
[0, { __typename: "Success", data: payload }],
[1, { __typename: "ApiError", code, message }]
)Compose a composite discriminator
When no single field decides the type, compute one with the methods reference. You then pass the composed value to ->match the same way. Suppose the API distinguishes physical from digital products with two fields:
{ "kind": "product", "subKind": "physical", "weight": 1.5 }Join them into a single discriminator string before matching:
... [kind, subKind]->joinNotNull(":")->match(
["product:physical", { __typename: "PhysicalProduct", weight }],
["product:digital", { __typename: "DigitalProduct", downloadUrl: url }]
)Discriminate by null versus non-null
When the only thing distinguishing two types is whether a field is present, the cleanest discriminator is the field itself, normalized to null when missing. Use ?? (nullish coalescing) inside $(...) to lift a possibly-missing field into the input value, then match null and @ (everything else) directly:
... $(name ?? null)->match(
[null, { __typename: "Anon" }],
[@, { __typename: "Named", name }]
)The first branch matches when name is missing or null. The second matches every other input value. This pattern reads top-down (anonymous case first, named case second), which is easier to follow than a presence check that has to put the catch-all branch first.
Always include a catch-all fallback
If your discriminator has values you didn't enumerate, the unmatched case becomes a runtime mapping error. These are your options for handling errors like that, in increasing strictness:
Map to a typed error.
[@, { __typename: "SearchError", message: "..." }], as in the preceding union example. Clients see the unrecognized object as a typed error and can render it accordingly.Map to a sentinel concrete type.
[@, { __typename: "UnknownProduct", raw }]. This approach is useful when the API occasionally returns new types that you haven't modeled yet but you still want clients to receive a typed result.Drop the object with
[@, null]. The list element becomesnullin the result, and the field becomesnullif it isn't a list. This approach is useful when you'd rather silently filter out unrecognized data than expose it.
What composition validates
Composition rejects a schema before deployment if any of the following cases are true:
A branch's
__typenameisn't a string literal.A branch's
__typenameliteral isn't a valid concrete type for the field's abstract type.A branch's other fields don't all exist on the concrete type its
__typenamenames.Multiple spreads in the same selection produce conflicting
__typenameliterals for the same logical position. For example, two branches both claiming to be"Book"but selecting incompatible fields.
These are static checks. You see them in the Rover CLI's output and in the GraphOS Studio composition view, not at runtime.
Federation: entities and @interfaceObject
An entity is a type with a @key directive that other subgraphs can extend. When a connector returns an entity that is also an abstract type, the composer enumerates every __typename literal your branches can produce and validates that each branch selects the @key fields of the concrete type its __typename names.
This example resolves a Person entity to either a Named or Anon concrete type, both keyed by id:
1extend schema
2 @link(
3 url: "https://specs.apollo.dev/connect/v0.4",
4 import: ["@source", "@connect"]
5 )
6
7@source(name: "people-api", http: { baseURL: "https://api.example.com" })
8
9interface Person {
10 id: ID!
11}
12
13type Named implements Person @key(fields: "id") {
14 id: ID!
15 name: String!
16}
17
18type Anon implements Person @key(fields: "id") {
19 id: ID!
20}
21
22type Query {
23 person(id: ID!): Person
24 @connect(
25 source: "people-api"
26 http: { GET: "/users/{$args.id}" }
27 entity: true
28 selection: """
29 id
30 ... $(name ?? null)->match(
31 [null, { __typename: "Anon" }],
32 [@, { __typename: "Named", name }]
33 )
34 """
35 )
36}id lives outside the spread because it's part of the Person interface and required by both @keys. Each branch then carries the concrete type's identity and its non-shared fields.
Apollo Connectors v0.4 also support @interfaceObject on connector subgraphs. The pattern is the same as anywhere else in federation: one subgraph defines the interface and its implementing types, and other subgraphs that contribute fields can use @interfaceObject to extend the whole interface without re-declaring each implementer.
Limitations
Abstract type support requires connect/v0.4. Enable it with the
preview_connect_v0_4setting in your router config. See Preview Features for the full setup.Earlier connect spec versions (v0.3 and earlier) reject schemas that define connector fields with abstract return types. For workarounds on those versions, see the limitations page.
If you encounter a case the preceding patterns don't address, tell us about it. Preview features evolve based on this feedback.
Related
Mapping Methods Reference, the full reference for
->match,->joinNotNull, and other arrow methods used here.Handling Responses, for broader guidance on shaping REST responses for GraphQL.
Error Handling, for the errors-as-data pattern in more detail.
Changelog, for what landed in connect/v0.4.