Inheritance in GraphQL: When and how to use it

Inheritance in GraphQL: When and how to use it

Discover how to make your GraphQL schema more readable, concise and easier to scale.

This article is an in-depth guide that walks you through an advanced use case of GraphQL directives. We will go through a hypothetical scenario in which using inheritance during schema development proves to be valuable and when incorporating such a pattern should be avoided. Ultimately, I will provide you with a real-world example implementation of type inheritance in GraphQL using a dedicated directive.

By the end of this article you will be able to:

  • recognize when and when not to use inheritance.

  • re-write your schema to use incorporate inheritance.

  • implement a GraphQL directive that makes inheritance possible in your schema.

  • how to use your directive implementation.

  • understand the limitations of inheritance

This article assumes you are already familiar with the basic concepts of GraphQL.
If that's the case you can jump directly into the implementation.

A possible scenario

We finally decided to develop a blog for ourselves. The goal is to share written articles on a small site. We heard throughout our development process that "GraphQL will kill REST" and therefore we change our mind about the technology we will use to query our data. So we decide to go with GraphQL, since it's new, and because we are cool.

After becoming familiar with the core concepts of GraphQL we come up with the following schema for our blog:

scalar Date
scalar JSON

type Post {
  id: ID! # Maybe user-provided, otherwise auto-generated
  headline: String!
  author: String! # Provided by the processing server
  content: JSON!
  tags: [String!]
  category: String
  subtitle: String 
  imageURL: String
  datePublished: Date # Provided by the processing server
  dateModified: Date # Provided by the processing server
}

input CreatePostInput {
  headline: String!
  imageURL: String
  content: JSON!
  tags: [String!]
  category: String
  subtitle: String 
}

input UpdatePostInput {
  id: ID!
  headline: String
  content: JSON
  imageURL: String
  tags: [String!]
  category: String
  subtitle: String 
}

extend type Query {
  posts: [Post!]
}

extend type Mutation {
  createPost(post: CreatePostInput): Post
  updatePost(post: UpdatePostInput): Post
}

All good! We go on and start setting up our Apollo Server, write resolvers for our schema and design a custom dashboard from where we publish articles to our blog. For the months to come, we are doing well.

A couple of months go by and we came to realize that we love photography.

Even though we are very active on Instagram, where we have been posting photos showing our cooking skills, this sole solution no longer fits our needs because we want to make the content we produce our own.

We decide to introduce a new feature to our blog that:

  1. can transform an article into an Instagram-compatible post.

  2. allows us to share a post with our audience on both Instagram and our blog.

We introduce a new type, CrossPlatformPost, to our schema:

union CrossPlatformPost = String | JSON

type CrossPlatformPost {
  id: ID! # Maybe user-provided, otherwise auto-generated
  title: String!
  author: String! # Provided by the processing server
  content: String
  tags: [String!]
  category: String
  subtitle: String 
  datePublished: Date # Provided by the processing server
  dateModified: Date # Provided by the processing server
}

input CreateCrossPlatformPostInput {
  headline: String!
  content: String!
  tags: [String!]
  category: String
  subtitle: String 
}

input UpdateCrossPlatformPostInput {
  id: ID!
  headline: String
  content: String
  tags: [String!]
  category: String
  subtitle: String 
}

extend type Query {
  crossPlatformPosts: [CrossPlatformPost!]
}

extend type Mutation {
  createCrossPlatformPost(post: CreateCrossPlatformPostInput): CrossPlatformPost
  updateCrossPlatformPost(post: UpdateCrossPlatformPostInput): CrossPlatformPost
}

A few months go by and during that time we became a neuroscience aficionado.

We have conducted research and would like to publish our findings somewhere. But while we can publish articles and cross-platform posts on our site, none of the types in our GraphQL schema provides an interface for publishing scientific papers.

To achieve that we must introduce a new GraphQL type to the family: the ScientificPaper type. Like the CrossPlatformPost type, the ScientificPaper type shares common fields with the Post type.

Following the same trajectory of designing a GraphQL schema, we can already foresee how it would look for the ScientificPaper type. We would copy and paste shared fields from the Post type and repeat this process for all other types to come.

So let's stop for a moment and gain some perspective on our endeavor because we might end up losing our minds copying and pasting the same fields over and over again.

Re-evaluating our initial approach

The current design approach is probably not an issue for small applications that solve a domain-specific problem that is unlikely to scale and includes domain models that barely share a common interface.

In contrast, in cases where a common interface represents the basis from which all (or many) domain models derive their fields, it might be worth reconsidering how we design our schema, such that we can easily compose types when our schema grows in size and complexity.

Some GraphQL specialists might argue that we should split our schemas using Federation - a technique to combine multiple GraphQL schemas from different, external sources into one single source of truth, a single GraphQL endpoint if you will.

This argument holds for mid to large-sized companies that have different teams working on a specific subset of domains and therefore have to access data across entities.

It has been my experience that for small teams and companies who, for the most part, work on a single domain, using federation can be a) very costly and b) introduces new, unnecessary complexities that are outside the scope of their domain.

Note: In the end, it depends on the complexity of your domain-specific requirements, and which solution solves the problem best.

Inheritance: An alternative solution

Why use inheritance

The main advantage of using inheritance when composing types is that it allows you to avoid duplicating fields that are shared by multiple types. This is certainly true in our hypothetical scenario where the Article, CrossPlatformPost and ScientificPaper types share fields from the Post type.

Instead of defining the same fields multiple times, you define them once in a parent type and then inherit them in the child types. Doing so greatly reduces the amount of code you need to write, and makes your schema more flexible, concise and easier to maintain.

Inheritance also proves to be more efficient and less error-prone than duplicating shared fields across multiple types, because any changes to the shared fields only need to be made in one place.

Rewriting our schema

scalar Date
scalar JSON

type SharedPostTypeFields {
  id: ID!
  title: String
  imageURL: String
  tags: [String!]
  category: String
  subtitle: String
  isPublished: Boolean!
  author: String! # Provided by the processing server
  datePublished: Date # Provided by the processing server
  dateModified: Date # Provided by the processing server
}

input SharedPostUpdateInputFields {
  id: ID!
  title: String
  imageURL: String
  tags: [String!]
  category: String
  subtitle: String
  isPublished: Boolean
}

input SharedPostCreateInputFields {
  id: ID
  title: String
  imageURL: String
  tags: [String!]
  category: String
  subtitle: String
}

Recap: What GraphQL directives are

Directives are used to add metadata and functionality to GraphQL types and/or their fields. This metadata can be read at runtime to modify the behaviour of a GraphQL schema.

There are two types of directives in GraphQL:

  • Schema directives
    Directives whose sole purpose is to attach metadata to a type or field.
    E.g: @deprecated

  • Operation directives
    Directives that manipulate the output of a GraphQL operation (query, mutation).
    E.g: @skip, @include

Use cases for directives include but are not limited to:

  • including or excluding fields

  • manipulating query arguments

  • controlling access to specific fields

A GraphQL directive is a pure Schema Definition Language (SDL) feature.

We declare directives using the following SDL syntax:

# A directive to enforce a min/max length on string-valued field.
directive @length(min: Int, max: Int) on FIELD_DEFINITION

type Tweet {
  # The content of a tweet cannot exceed a length of 160 characters.
  content: String! @length(max: 160)
}

Inheriting types using a dedicated directive

Now that we have a schema that defines shared fields between different kinds of Post types, let's introduce an imaginary @inherits directive to compose an Article type.

At the time of writing, there is no way other than using a special directive to derive fields from one or more types in GraphQL.

union ArticleContent = String | JSON

type Article @inherits(from: ["SharedPostTypeFields"]) {
  # One could use the `title` field here and make it a required field.
  # But to reflect the domain entity of an article, we introduce a new 
  # `headline` field, which is synonymous to the `title` field.
  headline: String!
  content: ArticleContent!
}

input UpdateArticleInput @inherits(from: ["SharedPostUpdateInputFields"]) {
  headline: String
  content: ArticleContent
}

input CreateArticleInput @inherits(from: ["SharedPostCreateInputFields"]) {
  headline: String!
  content: ArticleContent!
}

extend type Query {
  posts: [Article!]
}

We do the same for our old CrossPlatformPost type:

scalar ImageObject
scalar VideoObject

union MediaObject = ImageObject | VideoObject

type MediaPost @inherits(from: ["Post"]) {
  content: MediaObject!
}

input UpdateMediaPostInput @inherits(from: ["UpdatePostInput"]) {
  content: MediaObject
}

input CreateMediaPostInput @inherits(from: ["CreatePostInput"]) {
  content: MediaObject!
}

union Post = Article | MediaPost

extend type Query {
  posts: [Post!]
}

Lastly, we apply the same technique to the ScientificPaper type:


type ScientificPaper @inherits(from: ["Article"]) {
  abstract: String!
  contributors: [String!]
  publisher: String
  # ... etc
}

input UpdateScientificPaperInput @inherits(from: ["UpdateArticleInput"]) {
  abstract: String
  contributors: [String!]
  publisher: String
}

input CreateScientificPaperInput @inherits(from: ["CreateArticleInput"]) {
  abstract: String!
  contributors: [String!]
  publisher: String
}

union Post = Article | MediaPost | ScientificPaper

extend type Query {
  posts: [Post!]
}

Thus far, we have re-written our schema, so that types which share a common interface can derive fields from one another using an imaginary @inherits directive. But the directive is still just purely descriptive and has no functionality attached to it.

In the next section, we will go through the implementation details of our directive's functionality in digestible chunks.

Implementation

For the sake of clarity, I will include an abundance of comments in the code.
It's important to be cautious with excessive commenting. If the purpose of our code is not evident at a glance, it may be too smelly and require refactoring.

What we need

  1. TypeScript
    For better developer experience, tooling and type-checking support.

  2. GraphQL
    The JavaScript reference implementation for GraphQL.

  3. graphql-tools
    A Library provided by The Guild team that provides an intuitive API and handy tools for working with GraphQL schemas.

Creating a reliable and reusable, non-trivial schema directive can be tough, even with the help of great tools and best practices. It's a good idea to use a typed language like TypeScript since there are so many different schema types to consider.

Initial setup

import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import {
  GraphQLError,
  GraphQLInputObjectType,
  GraphQLInputObjectTypeConfig,
  GraphQLInterfaceType,
  GraphQLInterfaceTypeConfig,
  GraphQLObjectType,
  GraphQLObjectTypeConfig,
  GraphQLSchema,
  isInputObjectType,
  isInterfaceType,
  isObjectType,
} from 'graphql';

/**
 * GraphQL type from which fields can be inherited by the direcitve.
 * 
 * Note: We allow object types to inherit fields from input types 
 * in this example, which is for demonstration purposes only.
 * This should be avoided in production ready environments and/or 
 * more complex schemas.
 */
type Inheritable =
  | GraphQLInterfaceType
  | GraphQLObjectType
  | GraphQLInputObjectType;

/**
 * Check whether a GraphQL type is one of `Inheritable`.
 */
const isInheritable = (type: unknown): type is Inheritable => {
  return (
    type instanceof GraphQLObjectType ||
    type instanceof GraphQLInterfaceType ||
    type instanceof GraphQLInputObjectType
  );
};

/**
 * Valid locations within a GraphQL schema on which the `inherits`
 * directive can be applied to.
 */
export const DIRECTIVE_LOCATIONS = 'OBJECT | INPUT_OBJECT | INTERFACE';

Add types to represent our directive definition in TypeScript.

This step is optional, but since we are using TypeScript we should make use of its powerful type system. This might be redundant in some cases yet in most situations it helps catch bugs early on in our development process.

/**
 * A type representing a function that transforms a GraphQLSchema
 * and returns it.
 */
export type DirectiveTransformer = (schema: GraphQLSchema) => GraphQLSchema;

/**
 * Interface representing an object containing the GraphQl type
 * representation of the directive and a factory function that
 * applies the directive's implementation to a GraphQL schema.
 */
export interface DirectiveDefinition {
  typeDefs: string;
  transformer: DirectiveTransformer;
}

Clear and actionable errors

It's always a good practice to provide users with clear and actionable errors. For that reason, let's write some common error classes that extend the built-in GraphQLError.

export class InvalidInheritanceReceiverTypeError extends GraphQLError {
  constructor(typeName: string) {
    super(`Receiver type '${typeName}' is not a valid inheritance target. Must be one of ${DIRECTIVE_LOCATIONS}`)
  }
}

export class InvalidInheritableTypeError extends GraphQLError {

  constructor(typeName: string) {
    super(`Type '${typeName}' is not a valid inheritable type. Must be one of ${DIRECTIVE_LOCATIONS}`)
  }
}

export class TypeDoesNotExistError extends GraphQLError {
  constructor(typeName: string) {
    super(`Type '${typeName}' cannot be inherited because it does not exist in schema`)
  }
}

Where the magic happens

With this setup in place, we can move on to the next step where all the magic happens.

/**
 * Factory that returns a function which inherits fields from types specified
 * in the `from` argument of the inherits directive, given the following
 * conditions are met:
 *    - the directive is applied to an `inheritable` type
 *    - all types specified in the `from` argument of the directive are
 *      exist in the schema and are `inheritable`
 */
function inheritsDirectiveMapperFn<T extends Inheritable = Inheritable>(
  schema: GraphQLSchema,
  directiveName: string
) {
  // It's okay to use any for the `receiver` type here. 
  // This is because we immediately check the type before doing any work.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return (receiver: any): T => {
    // Note: We can omit this block in our case. The reason for that being
    // that we will assign the `inheritsDirectiveMapperFn` only to GraphQL
    // object types that are valid locations, as specified earlier in
    // `DIRECTIVE_LOCATIONS`.
    if (!isInheritable(receiver)) {
       // Only types that are themselves `inheritable` can inherit other
       // inheritable types.
       const receiverName = receiver?.name || receiver.toString();
       throw new InvalidInheritanceReceiverTypeError(receiverName)
    }

    const receiverConfig = receiver.toConfig();

    // Get the directive by `directiveName` on the current `receiver`.
    // If no such directive is applied to `receiver` this value 
    // will be undefined. Otherwise, this value contains a record of
    // key/value pairs of directive arguments and their values.
    // In our case, if the object is not undefined, the record will be:
    // {from: ['TypeToInherit1', 'TypeToInherit2', ....]
    const inheritsDirective = getDirective(
      schema,
      receiverConfig,
      directiveName
    )?.[0];

    if (!inheritsDirective) {
      // No directive by `directiveName` is applied to the `receiver` type.
      // We can leave the receiving type untouched and just return it.
      return receiver;
    }

    // Access the list of type names to find, copy the fields from
    // and ultimately add those fields to the current `receiver`.
    const typesToInherit: string[] = inheritsDirective['types'];

    typesToInherit.forEach(typeName => {
      // Attempt to find the type by name `typeName` in the schema.
      // Much like the `getDirective` function above, this value will be
      // undefined, if no such type is found.
      const type = schema.getType(typeName);

      if (!type) {
        // GraphQL type by provided `typeName` does not exist.
        throw new TypeDoesNotExistError(typeName)
      }

      if (!isInheritable(type)) {
        // GraphQL type by provided `typeName` is invalid.
        throw new InvalidInheritableTypeError(typeName);
      }

      const typeConfig = type.toConfig();
      const typeFields = Object.entries(typeConfig.fields);

      // Add/overwrite fields from the type to inherit to the `receiver`.
      typeFields.forEach(([name, field]) => {
        receiverConfig.fields[name] = field;
      });
    });

    // Construct a new GraphQL type from the current `receiver`.
    switch (true) {
      case isObjectType(receiver):
        return new GraphQLObjectType(
          receiverConfig as GraphQLObjectTypeConfig<unknown, unknown>
        );
      case isInputObjectType(receiver):
        return new GraphQLInputObjectType(
          receiverConfig as GraphQLInputObjectTypeConfig
        );
      case isInterfaceType(receiver):
        return new GraphQLInterfaceType(
          receiverConfig as GraphQLInterfaceTypeConfig<unknown, unknown>
        );
      default:
        // Note, this block should never be reached in our case.
        // This is because we checked for a valid `receiver` type right in
        // the beginning of this function.
        throw new GraphQLError(
          `Unknown error occured while applying '${directiveName}' directive onto '${receiver.name}'`
        );
    }
  };
}

Putting it all together

We have nearly reached our destination. The final piece of our implementation is about exporting a factory function that returns an object in the shape of DirectiveDefinition. The factory should allow us to customize the final directive name being used in our schema.

/**
 * A function that returns an object containing the type definitions for
 * the inherits and a function that applies the directive's functionality
 * to a schema.
 */
export function inheritsDirectiveDefinition(
  directiveName: string = 'inherits'
): DirectiveDefinition {
  return {
    typeDefs: `directive @${directiveName}(from: [String!]!) on ${DIRECTIVE_LOCATIONS}`,
    transformer: (schema: GraphQLSchema) => {
      const mapperFn = inheritsDirectiveMapperFn(schema, directiveName);
      return mapSchema(schema, {
        [MapperKind.OBJECT_TYPE]: mapperFn,
        [MapperKind.INPUT_OBJECT_TYPE]: mapperFn,
        [MapperKind.INTERFACE_TYPE]: mapperFn,
      });
    },
  };
}

Here is what's happening inside the inheritsDirectiveDefinition factory above:

  • create the typeDefs as it would appear in SDL.
    E.g.: directive @inherits(from: [String!]!) on OBJECT | INPUT_OBJECT | INTERFACE

  • assigns the inheritsDirectiveMapperFn to the appropriate GraphQL mapper kinds

You may have noticed the mapSchema function returned by our factory.
Remember, we need to transform our schema to apply functionality provided by our directive implementation and return a schema object. This function does just that.

Example Usage

To use our @inherits directive we need to apply the directive definition to our schema. This step might differ depending on how you have set up your GraphQL server, but here is an example of how you could do it:

import {makeExecutableSchema} from '@graphql-tools/schema';
import {inheritsDirectiveDefinition} from 'fake-inherits-directive-package';

const inheritsDirective = inheritsDirectiveDefinition('inherits');
const inheritsDirectiveTransformer = inheritsDirective.transformer;
const inheritsDirectiveTypeDefs = inheritsDirective.typeDefs;

const typeDefs = `
    ${inheritsDirectiveTypeDefs}

    type Author {
      firstName: String!
      lastName: String!
    }

    type Article {
      id: ID!
      headline: String!
      author: Author!
    }
`;

let schema = makeExecutableSchema({ typeDefs, resolvers });

// Apply `inherits` directive.
schema = inheritsDirectiveTransformer(schema);

// Your server implementation ....

Testing our solution

I will not go into detail about how to test the above directive implementation in this article. But for the aficionados amongst us that would like to go through a naive testing example for this directive, here is a GitHub link: Inherits directive test.

There is no great software without any proper testing. As with all features you write, you should test your implementations thoroughly. Implementing GraphQL directives is no exception to that rule.

Tests are a great way to outline a problem and find robust solutions for it while considering the edge cases of our initial problem statement. We are also less prone to implementation errors that would otherwise go undetected.

I would argue that we should start writing tests before even considering coding anything.

Limitations

Inheritance is not always the best solution for every use case. In some cases, it may be more appropriate to define fields explicitly in each type to ensure clarity and emphasize intent in the schema.

The @inherits directive, for instance, can introduce some complexity and overhead in the schema by making it difficult to see what fields will be included in the final, transformed type output. This may not be justified for simpler use cases.

When using Federation (e.g. Apollo Federation), modifying a type in a subgraph that is inherited by other types may strip away the @inherits directive's metadata.

Final words

Type inheritance in GraphQL is a common task in scenarios in which you have a lot of types that derive (a subset of) fields from a parent type. However, it is not natively supported and therefore we must make our hands dirty and implement such a feature on our own, using a dedicated directive.

In this article, we looked at a scenario that sticks to the classical approach of designing our schema and discussed why it might inhibit our development process and make it more difficult to add new features to our existing schema.

We went on to re-write our schema to fit the needs for incorporating inheritance and discovered why implementing inheritance in the GraphQL schema for our hypothetical blogging site proves to be effective.

Finally, we wrote the actual implementation of our imaginary directive's functionality and outlined the limitations of our solution.

This implementation is just an example and must be customized to fit your needs. You must also ensure to restrict inheritance by type, as I commented right at the beginning of the implementation.

Ultimately, your decision to incorporate inheritance in your schema boils down to the specific needs and requirements of your project, as well as the trade-offs between simplicity, flexibility, and maintainability you are willing to make.

Regarding using directives in your GraphQL schema

Directives should not be used in every situation. In some cases, they may lead to a noticeable decrease in performance or make your schema more complex and difficult to maintain.

It's important to keep your schema as simple as possible and to avoid using directives when they're not necessary. For example, you should avoid using directives for tasks that can be performed at the resolver level, such as data filtering or validation. Doing so can make it more difficult to debug and understand the flow of data in your application.

P.S

I would like to express my sincere gratitude to all of you who have made it to the end of this long article. Your willingness to invest your time and energy into expanding your knowledge is a testament to your intellectual curiosity and your desire to stay informed in an ever-evolving world.

Thank you for your attention and your dedication, and I hope that this article has provided you with valuable insights and knowledge.

Happy coding!

Did you find this article valuable?

Support The LetterFlow Digest by becoming a sponsor. Any amount is appreciated!