nitrogql logonitrogql

nitrogql 1.1 release: hello type-safe resolvers!

Published at September 16, 2023

Today, we are happy to announce release of nitrogql 1.1!

nitrogql is a toolchain for using GraphQL in TypeScript projects. In 1.1, we added support for generating type definitions for resolvers. This means that you can now get type safety on both the client-side and the server-side using nitrogql.

New in nitrogql 1.1

nitrogql 1.1 can generate two more TypeScript files than 1.0. They are:

These files are helpful for implementing GraphQL servers. In nitrogql'sschema-first approach, you write your GraphQL schema first, and then implement both the client and the server from it. The release of 1.1 fills the gap on the server-side; you can now get type safety for both the client and the server!

Configuring nitrogql for server side development

To generate the new files, you need to add some options to your configuration file. Namely, add resolversOutput and serverGraphqlOutput under the generate option.

schema: ./schema/*.graphql
documents: ./src/**/*.graphql
extensions:
  nitrogql:
    plugins:
      - "nitrogql:model-plugin"
    generate:
      schemaOutput: ./src/generated/schema.d.ts
      resolversOutput: ./src/generated/resolvers.d.ts
      serverGraphqlOutput: ./src/generated/server-graphql.ts
      # ...

With this setting, nitrogql generate will generate resolvers.d.ts and server-graphql.ts.

Writing resolvers with type safety

The generated resolvers.d.ts helps you write resolvers with type safety. It exports a Resolvers type, which is the type of the resolvers object you should implement. Suppose you have a schema like:

type Query {
  me: User!
}

type User {
  id: ID! @model
  name: String! @model
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID! @model
  title: String! @model
  content: String!
}

Then, you can use the Resolvers type:

import { Resolvers } from "./generated/resolvers";

type Context = {};

const resolvers: Resolvers<Context> = {
  Query: {
    me: async () => {
      // Returns the current user.
      return {
        id: "12345",
        name: "uhyo",
      }
    },
  },
  User: {
    email: async (user) => {
      const dbUser = await db.getUser(user.id);
      return dbUser.email;
    },
    posts: async (user) => {
      const dbPosts = await db.getPostsByUser(user.id);
      return dbPosts;
    },
  },
  Post: {
    content: async (post) => {
      const dbPost = await db.getPost(post.id);
      return dbPost.content;
    }
  },
};

Note that the Resolvers type is a generic type that takes the type of the context object as the type argument. Context is an object that is created per request and passed to all resolvers. You can use it to pass session information, database connections, and so on to resolvers.

Wait, what the hell is that @model thing?

Yeah you noticed that something unfamiliar is in the schema. It's the @model directive. It's a directive added by nitrogql (more specifically, the nitrogql:model plugin). This directive has been introduced along with the release of 1.1.

The fields marked with the @model directive are part of the model object of that type. This has two implications:

The @model directive exists for keeping it both practical and type-safe to implement resolvers. For type safety, we must ensure that resolvers are implemented for all the fields in the schema; otherwise it would be a runtime error. However, it's not practical to implement resolvers for every single field. That would be a lot of boilerplate code like id: (user) => user.id. This is where the default resolver comes in. The default resolver will behave as if you implemented that trivial resolver.

The @model directive is a way to tell nitrogql that you would like to use the default resolver for that field. nitrogql will recognize that directive and remove the field from the list of resolvers you need to implement. The point is that it is up to you to decide which resolver to implement and which resolver to leave for the default resolver. That's why you need to mark the fields manually with the @model directive. nitrogql didn't choose to implement some heuristic to automatically decide which fields use the default resolver. It would not be flexible enough.

As a consequence of the use of default resolvers, you need to include all the fields marked with @model directive to any object you return from a resolver (this is what we call the model object). This is because the default resolver will not be able to resolve the fields that you did not include in the object.

You know, GraphQL resolvers form a chain during the execution of a GraphQL query. When you return an object from a resolver, the next resolver in the chain will receive that object as the parent object. That's why resolvers receive the model object as the first argument. The @model directive affects communication between resolvers in this way.

Usage of @model

Now you know what the @model directive is for. Let's review the example above again. 😉

Looking at the schema, the model object for User type includes id and name fields. The email and posts fields are not included in the model object. Likewise, the model object for Post type includes id and title fields, but not content.

type Query {
  me: User!
}

type User {
  id: ID! @model
  name: String! @model
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID! @model
  title: String! @model
  content: String!
}

Next, look at the implementation of the me resolver. It returns an object that includes id and name fields. That's fine because the model object for User type includes those fields.

  Query: {
    me: async () => {
      // Returns the model object for User
      return {
        id: "12345",
        name: "uhyo",
      }
    },
  },

Looking at User resolvers, the email and posts resolvers are implemented because they are not marked with @model directive.

 User: {
    email: async (user) => {
      // user is of type { id: string; name: string }
      const dbUser = await db.getUser(user.id);
      return dbUser.email;
    },
    posts: async (user) => {
      const dbPosts = await db.getPostsByUser(user.id);
      return dbPosts;
    },
  },

As mentioned above, the user argument is the model object for User type. Thus it includes id and name fields. The email resolver uses the id field to fetch the email address from the database.

You can think of non-model field resolvers as another round of data fetching. The id field is the key for fetching more data from the database. User-returning resolvers include id field in the model object, so later round resolvers (like email and posts) can use it to fetch more data. In a practical situation you might use techniques like DataLoader to optimize the data fetching, but the same principle applies.

In this sense, the id field in User is necessarily included in the model object. On the other hand, the name field, which is not used for fetching more data, is not quite necessary to be included in the model object.

Then, why is name included in the model object? You know, this is for the sake of optimization. If name is queried so often, it would be better to fetch it in the first round of data fetching (i.e. the me resolver). If it's not included in the model object, another round of data fetching would be required to fetch the name. By utilizing the @model directive, you can easily optimize the data fetching still maintaining the type safety. Nicer optimization would require examining the whole query before entering the chain of resolvers, but that won't be that easy.

Replacing whole model object type with @model

If you are diligent enough, you might have been defining dedicated model classes for each type. For example, you might have been writing code like:

class User {
  readonly id: string;
  readonly name: string;

  constructor(id: string, name: string) {
    this.id = id;
    this.name = name;
  }
  async getEmail() {
    const dbUser = await db.getUser(this.id);
    return dbUser.email;
  }
  async getPosts() {
    const dbPosts = await db.getPostsByUser(this.id);
    return dbPosts;
  }
}

nitrogql supports this mode of defining models, though not very recommended. This is similar to GraphQL Code Generator's mappers option.

To use this class as the model object, you can use the @model directive on the entire type. For example:

type User @model(type: "import('@/model/user').User") {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

This tells nitrogql that the model object for GraphQL User type is an instance of the User class. With this setting, your resolver implementation can be like:

import { Resolvers } from "./generated/resolvers";
import { User } from "@/model/user";

type Context = {};

const resolvers: Resolvers<Context> = {
  Query: {
    me: async () => {
      // Returns the current user.
      return new User("12345", "uhyo");
    },
  },
  User: {
    // `user` is an instance of User class
    id: (user) => user.id,
    name: (user) => user.name,
    email: (user) => {
      return user.getEmail();
    },
    posts: (user) => {
      return user.getPosts();
    },
  },
  Post: {
    // ...
  },
};

Under this mode, you need to implement all field resolvers for the type.

Using the server GraphQL schema file

A smart reader might remember that nitrogql 1.1 also generates a server GraphQL schema file. Actually this file is simple; it just exports the GraphQL schema as a string. For example:

// generated by nitrogql
export const schema = `
type Query {
  me: User!
}

// ...
`;

Even if you have multiple .graphql files to form a schema, they are concatenated and exported as a single string. This reduces the burden of manually loading all those files when initializing a GraphQL server.

Also, this works as another layer of safety by ensuring that the schema you use at runtime is the same as the schema you used for generating type definitions. One configuration to rule them all is a great principle for reducing the chance of human errors.

You can use this file to initialize a GraphQL server. For example, with Apollo Server:

import { ApolloServer } from "@apollo/server";
import { schema } from "./generated/server-graphql";
import { Resolvers } from "./generated/resolvers";

const resolvers: Resolvers = { /* ... */ };

const server = new ApolloServer({
  typeDefs: schema,
  resolvers,
});

Schema cleanup

In fact, the server GraphQL schema file is not just a concatenation of all the .graphql files. It's also processed to remove all usage of the @model directive.

We knew that some of you would complain that they don't want to pollute their schema with directives that don't affect runtime behavior at all.

Our position on this is that we utilize the schema as the single source of truth for both at runtime and at compile time. If you need to annotate any GraphQL type with some information for compile time, we prefer to do that in the schema.

Anyway, since it isn't bad to remove compile-time-only directives from runtime code, we do that. The server GraphQL schema file is the result of this process.

The nitrogql:model plugin

In fact, all those @model story is implemented as a built-in plugin called nitrogql:model. You must enable this plugin in order to use the @model directive. As mentioned in the beginning of this article, this is done by adding the plugin to the plugins option.

schema: ./schema/*.graphql
documents: ./src/**/*.graphql
extensions:
  nitrogql:
    plugins:
      - "nitrogql:model"
    # ...

We felt that custom directives are too opinionated to be enabled by default. That's why we made it an opt-in feature.

However, resolvers type generation is almost unusable without the plugin. The default behavior is that the model object for each type includes all the fields in the type, and you still need to implement resolvers for all the fields. This is still type-safe but not very practical.

Type safety is a very important goal for nitrogql. Any combination of options should be type-safe, even if it's not practically usable.

To make resolver development practical while maintaining type safety, the developer must be able to specify which fields are included in the model object. This is why we introduced the @model directive through the plugin.

Maybe just for fun, let me share other options of default behavior without the plugin:

All fields in the model, all resolvers must be defined. This is the chosen option.

All fields in the model, no resolvers need to be defined (all fields use the default resolver). This is actually still type safe. However, this is likely to guide GraphQL beginners to wrong direction by making them think that they don't need to implement resolvers at all. This is the opposite of how GraphQL is supposed to be used. We don't want to encourage beginners to do that.

Fields in the model are optional, all resolvers must be defined. This is kind of safe because as long as all resolvers are defined, it never fails to return needed data. However, this setting would force you to write a lot of boring boilerplate code. Proper type definitions should be able to improve the developer experience than this.

Fields in the model are optional and resolvers are also optional. This is the default behavior of GraphQL Code Generator. But we don't do this because it's not type safe. If you omit a field from a resolver return value and also don't implement an explicit resolver for it, it will be a runtime error.

What's Next?

In fact our roadmap is empty now. This doesn't mean that we're done with nitrogql. We're considering some ideas for the next release, but we haven't decided yet.

If you have any ideas or requests, please let us know on GitHub. We're waiting for your feedback!

Conclusion

nitrogql 1.1 is a big step towards the goal of type safety on both the client and the server. Now you can get type safety on both sides using the same GraphQL schema. We hope that this will make GraphQL development more enjoyable.

In the last article, we said that GraphQL Code Generator's resolver type generation is not type-safe by default. Actually, the mappers option is the only way to get type safety in a reasonable way with that tool.

While nitrogql supports the same way of defining models (by applying the @model directive to the entire type), we have another way applying the directive to each field. We like this way better because this is easer to use and it doesn't require additional type definitions external to resolver implementations.

In conclusion, this release introduces something unfamiliar to you, but we believe that it is nice enough. We hope that you will like it too.

nitrogql is developed by uhyo. Contribution is more than welcome!