Apollo Client's Hidden Gems: Interface-Based Type Policies

Picture of the author
Nicolas CharpentierJanuary 22, 2024
4 min read
Apollo Client's Hidden Gems: Interface-Based Type Policies

We can use Apollo Client Type Policies for a variety of things, like a common one is to define a field policy with:

  • A read function that specifies what happens when the field's cached value is read. Which can be useful for example to transform the data before it's returned without changing the data in the cache (such as rounding floating-point values to the nearest integer), or even deriving local-only fields (such as deriving an age field from a birthDate field).
  • A merge function that specifies what happens when field's cached value is written. Which can be useful for merging non-normalized data, or even normalize individual fields at write-time (such as normalizing a string or number).

But did you know that type policies can also be applied to interfaces and not just to "types"!? 🤯

Applying type policies to interfaces can be really useful when you have a repeating pattern and you end up having to define the same field policy each time.

Interfaces and TypePolicy inheritance

I recently leveraged that pattern where we had multiple *_DataPoint types that all shared a common field: sampledAt, from which we need to derive the value into a local-only field containing the epoch representation of sampledAt as sampledAtEpoch.

So, instead of defining the same field policy for each DataPoint implementer, I extracted the common field to an interface and defined the field policy on the interface instead:

# Server schema
interface DataPoint {
  sampledAt: DateTime!
}

type A_DataPoint implements DataPoint {
  sampledAt: DateTime!
  value: Float!
}

type B_DataPoint implements DataPoint {
  sampledAt: DateTime!
  value: Float!
}

# Client schema
# Since this is a local-only field, which isn't known to the server,
# we need to define it on the client schema
extend type A_DataPoint {
  sampledAtEpoch: Int!
}

extend type B_DataPoint {
  sampledAtEpoch: Int!
}

You may notice that in order to type our local-only field sampledAtEpoch, we need to extend both implementers: A_DataPoint and B_DataPoint. It would be useful to be able to extend the interface rather than each implementer, unfortunately, this is a limitation of GraphQL with interfaces.

Good news is that we can rely on the inheritence of type and fields policies via possibleTypes. This means that we can define a type policy on the interface and it will be inherited by the implementers:

const cache = new InMemoryCache({
  possibleTypes: {
    DataPoint: ["A_DataPoint", "B_DataPoint"],
  },

  typePolicies: {
    DataPoint: {
      fields: {
        // Derives the `sampledAtEpoch` field from the `sampledAt` field,
        // by turning a `Date` into an Epoch.
        sampledAtEpoch: {
          read(_, { readField }) {
            const sampledAt = readField('sampledAt');

            if (!sampledAt) return null;

            return Date.parse(sampledAt);
          },
        },
      },
  },
  },
});

⚠️ possibleTypes field needs to be filled in, otherwise, Apollo Client won't know about the interface implementers and won't be able to inherit the type policy. But I highly encourage you to look into generating it automatically.

See:

👋 If you're using GraphQL Code Generator, this is what you're looking for: https://the-guild.dev/graphql/codegen/plugins/other/fragment-matcher. Either way, I highly encourage you to look into GraphQL Code Generator.

Then, just like this, each implementer of DataPoint will automatically inherit the sampledAtEpoch field policy! No need to repeat the field policy over and over for each impleemnter.

Deserializing a Date

It doesn't need to be combined with a local-only field, once we've set up possibleTypes, it could be used for simpler use cases too that only requires a read/merge function.

One example of this would be the deserialization of a DateTime scalar. Let's say we have a DateTime scalar on our server schema and we would like to deserialize it from a string to a Date object on the client.

Let's pick our previous example with sampledAt field being a DateTime, but rather parsed as a string. We can define a type policy on the interface to deserialize the sampledAt field as a Date object via a read function:

const cache = new InMemoryCache({
  possibleTypes: {
    DataPoint: ["A_DataPoint", "B_DataPoint"],
  },

  typePolicies: {
    DataPoint: {
      fields: {
        // Deserializes the `sampledAt` field from a string to a `Date` object.
        sampledAt: {
          read(sampledAt) {
            return new Date(sampledAt);
          },
        }
      },
  },
  },
});

That way, everytime we're querying for sampledAt from DataPoint interface, it will be deserialized as a Date object rather than a string!

There are multiple other possibilities with interfaces and type policies, but I hope this gives you a good idea of what's possible!

📚 References