Jovi De Croock
Software Engineer
GraphQL Abstract types and caching
An issue in GraphQL client's normalised caching are abstract types and when you as a consumer run into them it can be really hard...
We'll use the terms abstract and concrete types which can be defined as follows:
- Abstract type, an interface or union type, interfaces can be implemented by interfaces/objects making the implementor have a superset of the fields of the implemented interface.
- Concrete type, an object that possibly implements an interface, these are object-types that represent an eventual returned value.
Introduction
To get this we need to rewind a bit and look at the fundamentals of normalized caching, and how it works conceptually. Let's say we get the following selection-set from our client:
query {
pokemon(id: "1") {
id
name
}
}
A normalized cache will go over this and start checking whether or not it contains every field by doing the following in order:
Cache.read('Query.pokemon(id: 1)')
- this will return either nothing or a pointer to thePokemon
in cache.Cache.read('Pokemon:1.id')
- this will return either nothing or theid
of thePokemon
Cache.read('Pokemon:1.name')
- this will return either nothing or thename
of thePokemon
That's the gist of the operations that will be executed, the reason we can uniquely identify these
entities is because GraphQL exposes the __typename
field on every object, which allows us to combine
a unique identifier and the name of the type to create a unique key.
If the entity isn't there we'll reach out to the server and we'll be able to write the response in the cache with very similar steps.
Now the above is an ideal case, there are also going to be cases where we can't generate a key
for a given entity, in urql
we call them embedded entities. This basically opts out of normalization
for these objects and will embed the object in the parent field.
cacheExchange({
keys: {
Pokemon: p => p.id, // Default
PokemonDimension: () => null, // embedded entity, will be embedded into Pokemon.dimension
}
})
Now to add one more piece we'll look at an abstract type being queried...
query {
beast(id: "1") {
id
... on Beast {
name
}
... on Pokemon {
fleeRate
}
... on Digimon {
evolutionLevel
}
}
}
For clarity the schema looks like
interface Beast {
id: ID!
name: String!
}
type Pokemon implements Beast {
id: ID!
name: String!
fleeRate: Float!
}
type Digimon implements Beast {
id: ID!
name: String!
evolutionLevel: Int!
}
type Query {
beast(id: ID!): Beast
}
The steps we'll go through to resolve this query will be the following
Cache.read('Query.beast(id: 1)')
- this will return either nothing or a pointer to thePokemon|Digimon
in cache.Cache.read('(Pokemon|Digimon):1.id')
- this will return either nothing or theid
of thePokemon|Digimon
Cache.read('(Beast):1.name')
- this will return either nothing or thename
of theBeast
--> Problematic as we'll never see this__typename
as the server will always reply with a concrete type ofPokemon
orDigimon
meaning that we'll never be able to resolve this field as we can't be sure whether this is a concrete or abstract-type.Cache.read('(Pokemon):1.fleeRate')
- this will return either nothing or thefleeRate
of thePokemon
Cache.read('(Digimon):1.evolutionLevel')
- this will return either nothing or theevolutionLevel
of theDigimon
When we are writing to the cache we'll always write with the __typename
we are given and we'll check the fragments
whether they heuristically match the response. This means that if we get back a Pokemon
, fleeRate
will be found in the
response and name
will be found in the response meaning that these two responses heuristically match. We won't be able
to find evolutionLevel
in the response so we can ignore that fragment.
The issue here becomes that for a Pokemon we'll get the name
and fleeRate
returned while for a Digimon we'll get the name
and
evolutionLevel
returned. The Beast Fragment represents the fields they have in common but as an abstract type will never be selection
explicitly.
Interface fields
The above shows the issue that normalized caches face, to fix that you'll see solutions like
Apollo's possibleTypes or
urql's schema-awareness. Solutions like
these allow us to lookup each type and see that it's abstract which in turn allows us to resolve the selection-set by means of the
__typename
of the implementing types.
Something that can be said is whether we can construct possibleTypes
at query-time where we would see that Pokemon
is returned, and
we see that it has the name
property which would allow us to derive that by extension Pokemon is implementing the Beast interface.