iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔥

nitrogql 1.1 Release: Hello Type-Safe Resolvers!

に公開

This article is a Japanese translation of the following article published yesterday. With the release of nitrogql 1.1, the resolver type generation feature is now available, allowing you to write both server and client sides in a type-safe manner using only nitrogql. The original article can be found here.

https://nitrogql.vercel.app/blog/release-1.1


Today, we released nitrogql 1.1!

nitrogql is a toolchain for using GraphQL in TypeScript projects. In 1.1, we added a feature to generate type definitions for resolvers. This allows you to use GraphQL type-safely on both the client and server sides by using nitrogql.

New Features in nitrogql 1.1

In nitrogql 1.1, in addition to the features in 1.0, two new TypeScript files can now be generated.

  • The Resolver type definition file defines the types of the resolvers that should be implemented.
  • The Server GraphQL schema file makes it easy to pass the GraphQL schema to the GraphQL server at runtime.

These files help with the implementation of GraphQL servers. In the schema-first approach adopted by nitrogql, you first write the GraphQL schema and then implement both the client and server based on it. With the release of 1.1, the server-side gap has been filled. Now you can use GraphQL type-safely on both the client and server sides!

nitrogql Configuration for Server Development

To generate these new files, you need to add several options to the configuration file. Specifically, add resolversOutput and serverGraphqlOutput under the generate option.

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

By adding this configuration, running nitrogql generate will generate resolvers.d.ts and server-graphql.ts.

Implementing Resolvers Type-Safely

The generated resolvers.d.ts helps you implement resolvers in a type-safe manner. This file exports the Resolvers type, which is the type of the resolver object to be implemented. For example, suppose you have the following schema:

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, the generated Resolvers type can be used as follows:

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;
    }
  },
};

The Resolvers type is a generic type that takes the type of the context object as a type argument. The context is created for each request and passed to all resolvers. This can be used to pass session information, database connections, and more to the resolvers.

Wait, what is this @model thing?

Right. In the previous schema, there was something unfamiliar. It's the @model directive. This is a directive added by nitrogql (specifically, by the nitrogql:model plugin). This directive was introduced with the release of 1.1.

Fields given the @model directive become part of the model object of that type. This has two meanings:

  • You don't need to implement a resolver for fields given the @model directive. Default resolvers will handle those fields.
  • When returning an object of that type from a resolver, you must include all fields given the @model directive.

The @model directive exists to balance practicality and type safety when implementing resolvers. Type safety refers to ensuring that resolvers are implemented for all fields present in the schema. If this is not met, it will result in a runtime error. However, it's not practical to have to implement a resolver for every single field without exception. This is because you would end up writing a lot of boilerplate code like id: (user) => user.id. This is where default resolvers are useful. Default resolvers behave like these trivial resolvers.

The @model directive tells nitrogql that you want to use a default resolver for that field. nitrogql recognizes this directive and removes the field from the list of resolvers you must implement. Importantly, it is up to you which resolvers you implement and which you leave to the default resolvers. That's why you need to manually write the @model directive for the necessary fields. We could have had nitrogql automatically determine which fields use default resolvers, but we didn't go with that implementation because it lacks flexibility.

As a consequence of using default resolvers, the object returned from a resolver must include all fields given the @model directive (we call this the model object). This is because default resolvers cannot resolve fields that are not included in the model object.

As you know, GraphQL resolvers form a chain during the execution of a GraphQL query. That is, when a resolver returns an object, the next resolver in the chain receives that object as the parent object. Therefore, the first argument a resolver receives is the model object. The @model directive thus also affects the passing of data between resolvers.

How to use @model

Now we understand why the @model directive was introduced. Let's look at the previous example again. 😉

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

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, let's look at the implementation of the me resolver. This resolver returns an object containing the id and name fields. This matches the fact that the model object for the User type contains these fields.

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

Looking at the User resolvers, the resolvers for email and posts are implemented. This is because these fields are not given the @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 earlier, the user argument is the model object for the User type. Therefore, it contains the id and name fields. The email resolver uses the id field to fetch the email address from the database.

For resolvers of fields that are not given the @model directive, you can think of it as additional data fetching occurring. The id field is a key for fetching more data from the database, and since resolvers returning the User type include the id field in the model object, subsequent resolvers (like email or posts) can use this to fetch more data. In a real-world situation, you might use techniques like DataLoader to optimize data fetching, but the concept is the same.

Considering this, it's inevitable that the id field is included in the model object for the User type. On the other hand, the name field is not used for data fetching, so there's no necessity for it to be in the model object.

So why is the name field included in the model object? Actually, it's for optimization. If name is fetched frequently, it's better to fetch it together during the initial data fetch (i.e., the me resolver). If it weren't in the model object, an additional round trip of data fetching would be needed to get the name. By using the @model directive, you can easily optimize data fetching while maintaining type safety. If you want even more advanced optimization, you'd need to inspect the entire query before entering the resolver chain, but that's not something that can be done so easily.

Specifying the entire model object type with @model

If you are a diligent person, you might have defined dedicated model classes for each type. For example, you might be writing code like this:

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 also supports this way of defining models (though it's not highly recommended). This is similar to the mappers option in GraphQL Code Generator.

To use this class as a model object, provide the @model directive to the type itself. 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 corresponding to the GraphQL User type is an instance of the User class. With this configuration, the resolver implementation would look like this:

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: {
    // ...
  },
};

In this case, you need to implement resolvers for all fields of User.

Using the Server GraphQL Schema File

Shrewd readers might remember that nitrogql 1.1 also added a feature to generate server GraphQL schema files. The role of 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 there are multiple original .graphql files, they are combined and exported as a single string. This saves you the trouble of manually loading these files when initializing the GraphQL server.

This file also serves as an additional safety guarantee. It ensures that the schema used at runtime is the same as the schema used to generate the type definitions. Consolidating everything into one configuration file is a great principle for reducing the possibility of human error.

This file can be used when initializing the GraphQL server. For example, when using Apollo Server, it looks like this:

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

Actually, the server GraphQL schema file is not just a simple concatenation of .graphql files. It undergoes a process to remove all @model directives.

We did this because we knew some people might be reluctant to include directives in the schema that have no effect on runtime behavior.

Our philosophy is that the schema should be used as the Single Source of Truth for both runtime and compile-time. If you need to annotate GraphQL types for use at compile-time, we prefer to write it in the schema.

That said, it's not a bad thing to remove directives used only at compile-time from the runtime code. Therefore, this process is applied to the server GraphQL schema file.

The nitrogql:model Plugin

Actually, the @model directive is entirely implemented by a built-in plugin named nitrogql:model. To use the @model directive, you need to enable this plugin. As mentioned briefly at the beginning of this article, you can enable it by adding it to the plugins option in your configuration file.

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

As such, the @model directive is an opt-in feature. This is because we felt that adding custom directives by default would be a bit too opinionated.

However, without the @model directive, resolver type generation is hardly useful. By default, the model object for each type includes all fields of that type, and you are also required to implement resolvers for all fields. While this is type-safe, it is not practical.

Type safety is a very important goal for nitrogql. Type safety should be maintained regardless of the combination of options, and nitrogql prioritizes safety over practicality.

To make resolver development practical while maintaining type safety, we needed a way for developers to specify which fields are included in the model object. This is why we introduced the @model directive via a plugin.

By the way, there were other options for the default behavior when the plugin is absent. I'll share them here as they might be interesting:

  • All fields are included in the model, and resolvers must be implemented for all fields. This is the option that was chosen.
  • All fields are included in the model, and no resolvers need to be implemented (all fields use default resolvers). This is actually type-safe as well. However, this might lead GraphQL beginners in the wrong direction, making them think they don't need to implement resolvers at all. This defeats the purpose of using GraphQL. We don't want to encourage beginners to use it that way.
  • Model fields are optional, and resolvers must be implemented for all fields. This is safe in a sense because as long as all resolvers are implemented, they can return the necessary data. However, this setup would require writing a lot of boilerplate code. Better developer experience should be possible with proper type definitions.
  • Model fields are optional, and resolvers are also optional. This is the default behavior of GraphQL Code Generator. However, we couldn't adopt this because it is not type-safe. If you omit a field from the resolver return value and also don't implement a resolver for that field, it results in a runtime error.

What's Next?

Actually, there is currently nothing on the roadmap. This doesn't mean development of nitrogql has finished. We are considering several ideas for the next release, but nothing has been decided yet.

If you have ideas or requests, please let us know on GitHub. We look forward to your feedback!

Conclusion

nitrogql 1.1 is a big step toward achieving type safety on both the client and server sides. Now, you can use the same GraphQL schema to get type safety on both sides. We hope this makes GraphQL development more enjoyable.

In the previous article, I mentioned that GraphQL Code Generator's resolver type generation is not type-safe by default. In fact, the only way to get type safety and practicality with GraphQL Code Generator is through the mappers option.

nitrogql supports a similar approach (specifying the @model directive on the type itself), but it also allows specifying directives on a per-field basis. We prefer this method because it's easier to use and doesn't require external type definitions for resolver implementations.

While this release introduces things that might be unfamiliar to you, we believe it's a very positive direction. We hope you like it too.

GitHubで編集を提案

Discussion