Skip to main content

Abstract types and polymorphism

GraphQL supports two abstract types suitable for usage in output: interfaces and unions. An interface defines a list of fields; all objects that implement the interface must implement fields compatible with these. A union is a simple list of possible object types. At runtime, positions that return one of these abstract types must resolve to a concrete object type; thus abstract types allow GraphQL to describe positions where data polymorphism can occur.

Grafast supports GraphQL interfaces and unions, both through traditional GraphQL.js resolvers and through plan resolvers. Resolvers work the same (basically) as they do in GraphQL.js so we won’t dig into them here, but let’s look into how Grafast supports abstract types via plan resolvers (which enables greater efficiency!).

Polymorphic positions

To make it easier to talk about planning a polymorphic GraphQL query, let’s define the term “polymorphic position” to refer to a position in our GraphQL operation whose return type is an abstract type (an interface or union).

Imagine you have a GraphQL schema such as:

interface Animal {
name: String!
}
type Cat extends Animal {
name: String!
numberOfLives: Int!
}
type Dog extends Animal {
name: String!
wagsTail: Boolean!
}
type Query {
bestAnimal: Animal
randomAnimals: [Animal]
}

One query to this schema could be:

query BestAnimal {
bestAnimal {
name
... on Cat {
numberOfLives
}
... on Dog {
wagsTail
}
}
}

Here the return type of the bestAnimal field is an abstract type (Animal, which is an interface), so the return type of bestAnimal is a polymorphic position in this query.

Another query could be:

query RandomAnimals {
randomAnimals {
name
... on Cat {
numberOfLives
}
... on Dog {
wagsTail
}
}
}

Here the return type of randomAnimals is a list ([Animal]). A list is not itself abstract, however the type inside the list is an abstract type (Animal again), so a polymorphic position in this query is inside the list returned by randomAnimals.

Operations may have any number (0 or higher) of polymorphic positions.

Planning polymorphism

Planning a polymorphic position is a collaboration between the field’s plan resolver and the abstract type’s planType method, which describes how to resolve abstract values into their concrete type and associated data.

The plan returned by a field plan resolver representing the value to use for a polymorphic position is called a “specifier”. It’s called a specifier because it does not actually have to be the value itself (though it can be!), it can just be a description of that item sufficient for your abstract type to know how to fetch it.

Grafast recognizes that sometimes you need to fetch things to figure out what type they are, and sometimes you need to know what type they are in order to fetch them (and sometimes it’s a little of both).

For example you might have an “animals” table in your database that details if an individual record is a cat, dog, fish, budgie or similar. In this case you need to fetch the record in order to determine the type of the value. We’ll call this “fetch-to-type”, and for it we can have the specifier be the record’s primary key, which we’ll call id.

An opposing example would be a GraphQL Node ID: here a string such as User:1 (but typically Base64 encoded) indicates the type up front, along with an identifier, and you use this type to determine how to fetch the record. We’ll call this “type-to-fetch”, and the specifier is this string we mentioned: User:1.

%%{init: {'themeVariables': { 'fontSize': '12px'}}}%% graph TD subgraph NodeID["Node ID: &ldquo;type-to-fetch&rdquo;"] v["$nodeId<br>(e.g. User:1 or Organization:2)"] v-->|isUser?| GetUser[["Fetch user by id"]] v-->|isOrganization?| GetOrg[["Fetch organization by id"]] GetUser --> User GetOrg --> Organization end subgraph Vets["Shared storage: &ldquo;fetch-to-type&rdquo;"] v2["$animalId<br>(e.g. 1, 2, 3)"] v2-->Animal[["Fetch animal by id"]] Animal-->|isCat?| Cat Animal-->|isDog?| Dog end
The difference in using “fetch-to-type” and “type-to-fetch”: In “fetch-to-type”, the record must be fetched before the type can be known; in “type-to-fetch”, the type is known up-front and the way the record is fetched depends on that.

The specifier that a given abstract type requires is based on the expectations of its planType method, which the schema author supplies.

planType

If you use makeGrafastSchema() then you can specify planType on the interface or union type:

const schema = makeGrafastSchema({
typeDefs: /* GraphQL */ `
interface Animal {
id: ID!
#...
}
union Entity = Animal | Alien
`,
interfaces: {
Animal: {
planType($specifier) {
/* ... */
},
// Also: toSpecifier($step)
},
},
unions: {
Entity: {
planType($specifier) {
/* ... */
},
// Also: toSpecifier($step)
},
},
});

If you’re using GraphQL.js or another construction mechanism then the method actually lives inside extensions:

const Animal = new GraphQLInterfaceType({
name: "Animal",
fields: {
/* ... */
},
extensions: {
grafast: {
planType($specifier) {
/* ... */
},
// Also: toSpecifier($step)
},
},
});
const Entity = new GraphQLUnionType({
name: "Entity",
types: [Animal /* , ... */],
extensions: {
grafast: {
planType($specifier) {
/* ... */
},
// Also: toSpecifier($step)
},
},
});

However you add it, planType accepts two parameters: the specifier step, and an info object, which you can find out more about in “Advanced” below. The function must return an AbstractTypePlanner object, which is an object with two entries:

  • $__typename (required): a step representing the name of the resolved concrete object type.
  • planForType(t) (optional): a function that is called for each possible concrete object type, t, and must return the step to use for that type (or null if creating such a step is not possible). If unspecified, the $specifier step will be used for all object types.

In our fetch-to-type example from before, planType might look like this:

function planType($specifier: Step<number>): AbstractTypePlanner {
// Fetch the database record for this $specifier
const $record = animals.get({ id: $specifier });
// Extract the type from the relevant column
const $type = $record.get("type");
// Convert that to a GraphQL type name
const $__typename = lambda($type, animalTypeNameFromType, true);
// Return our polymorphic type planner
return {
$__typename,
planForType(t) {
// All the different types are represented by data from the same resource
return $record;
},
};
}

// Turns a database value such as "cat" into a GraphQL type name such as "Cat"
function animalTypeNameFromType(type: string) {
return (
{ cat: "Cat", dog: "Dog", fish: "Fish", budgie: "Budgie" }[type] ?? null
);
}

However, in our type-to-fetch example, planType would perform more logic inside the planForType method, and needs less work to find the type name:

function planType($specifier: Step<string>): AbstractTypePlanner {
// Parse the NodeID into a typename and identifier
const $parsed = lambda($specifier, parseNodeId, true);
// Extract the type name:
const $__typename = get($parsed, "__typename");
// Return our polymorphic type planner
return {
$__typename,
planForType(t) {
// Each different type has its own plan:
switch (t.name) {
// This is the 'User' type, so extract the ID and fetch the user:
case "User": {
const $id = get(parsed, "id");
return users.get({ id: $id });
}
// Resolving other types may work in similar or different ways:
case "Organization": {
const $id = get(parsed, "id");
return organizations.get({ organizationId: $id });
}
default: {
console.warn(`Don't know how to fetch ${t.name}`);
return null;
}
}
},
};
}

function parseNodeId(nodeId: string) {
const [__typename, rawId] = nodeId.split(":");
const id = parseInt(rawId, 10);
return { __typename, id };
}

Currently Grafast will call planForType (if present) for each possible object type at a given polymorphic position, and then will group these by the ones returning the same (or equivalent) steps into a polymorphicPartition and continue planning for each type from there.

Not optimal enough? Get in touch!

Walking all possible object types is a simple approach, but it can inflate the time spent planning an operation, especially for highly polymorphic operations. We minimize this cost by fanning in before fanning out (see the below infobox), but there’s space for more optimization: at some point, Grafast might add support for on-demand polymorphic planning.

With on-demand polymorphic planning, each “polymorphic branch” of the plan will only be planned the first time that an object of that type is met at runtime. This on-demand polymorphic planning strategy should significantly decrease initial planning time for highly polymorphic operations, and may result in many of the paths never needing to be planned at all. If this is something your deployment of Grafast needs, please get in touch.

Grafast avoids exponential branching

Note the type-to-fetch example causes the operation plan to branch: User and Organization each have different steps, and so we split them into separate polymorphicPartition layer plans.

Excessive branching complicates both planning and execution and could lead to denial of service. Grafast made the fundamental choice to “fan in” before “fanning out” in polymorphism to avoid exponential branching, and that choice has had a significant impact on the shape of the APIs it makes available.

If planned naively, nested polymorphic positions could lead to significant branching. If each polymorphic position could represent P different types and we queried D levels deep then recursing through each different type at each level would produce up to P^D different branches — the complexity would scale exponentially with query depth. This is good news for an attacker, and bad news for our server bills.

Grafast prevents this from happening by forcing polymorphism to “fan in” before it “fans out” again:

%%{init: {'themeVariables': { 'fontSize': '12px'}}}%% graph TD v["$nodeId<br>(e.g. 'User:1' or 'Organization:2')"] v-->|isUser?| GetUser[["Fetch user by id"]] v-->|isOrganization?| GetOrg[["Fetch organization by id"]] GetUser --> UserPets[/"getPetIds"\] GetOrg --> OrgPets[/"getServiceAnimalIds"\] UserPets & OrgPets -.-x Combined@{shape: docs, label: "Combined ids"} Combined-->Animal[["Fetch animal by id"]] Animal--> Cat Animal--> Dog
getPetIds and getServiceAnimals “fan in” to one combined node in order to then fetch all of the Animals by their IDs. Once the IDs are fetched, the nodes “fan out” to the different Animal types.

So for ten types (P=10) and five levels (D=10) instead of having up to 10,000,000,000 (P^D) branches; Grafast scales linearly with depth meaning there’s only up to 100 (P*D) branches.

Advanced

If you want to be hyper-optimal, you may not want to always return a specifier from your field plan resolvers — you may want to return and use a step that already represents the relevant data that you already needed for some reason. However, if this field is queried through multiple polymorphic paths Grafast will perform “fan in” to prevent excessive branching. To do so, it will take the step returned from each of the relevant field plan resolvers and if there’s more than one, it will do the first of these that yields results:

  1. it will call the abstract type’s toSpecifier($step) method, if present, passing the step; or
  2. it will call the step’s $step.toRecord() method, if present; or
  3. it will use $step’s data directly.

Whichever of these it does, it will create a “combined data” step representing just the data from these steps, yielded via the above options. This combined step will then be input to planType as the specifier.

For consistency, Grafast will also call toSpecifier($step) (if present) when there’s only one step - this ensures the “specifier” step passed in to planType contains consistent data whether there’s one step or many. However, if there was just one step then it’ll also make available the original step via the info argument (info.$original); you may use this if you want to be extra efficient somehow.

As shown in the planType examples above, toSpecifier also goes in the union or interface type’s extensions (extensions.grafast.toSpecifier); how you would populate this depends on the framework you’re using; here’s an example using makeGrafastSchema:

const schema = makeGrafastSchema({
typeDefs: /* GraphQL */ `
interface Animal {
id: ID!
#...
}
`,
interfaces: {
Animal: {
toSpecifier($step) {
// A simple data object with just the ID, perfect for accumulating
return object({ id: get($step, "id") });
},
planType($obj) {
// Extract the ID
const $id = get($obj, "id");
// Fetch the record (same for all types)
const $record = loadOne($id, batchGetAnimalById);
// Determine the GraphQL type name for the record
const $__typename = $record.get("typename");
// Return our polymorphic planner
return {
$__typename,
planForType() {
// We've already fetched the record, which is polymorphic, and we've
// already determined the type; all types can thus share this same
// step.
return $record;
},
};
},
},
},
});

Caveats

Though Grafast limits the impact of abstract types, they are still quite expensive compared to regular types (a field returning an abstract type that could be N different concrete types can be similarly complex to having N fields that return concrete types). As such, to protect your servers from malicious queries we recommend that you implement trusted documents if you can, and if not that you apply an abstract type depth limit to avoid polymorphism’s branching becoming an attack vector for your schema. Grafast also makes available planning timeouts, but these should be your fallback defence rather than your primary defence.