nitrogql logonitrogql

nitrogql 1.6 release: improved treatment of scalar types

Published at January 6, 2023

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

nitrogql is a toolchain for using GraphQL in TypeScript projects. In 1.6, treatment of scalar types has been improved. Now each GraphQL scalar type can have different TypeScript type in different situations.

Problem of the ID type

Let's start the story with the ID type. ID is a scalar type that is built into GraphQL. It is used to represent unique identifiers.

What is special about ID is that integers, as well as strings, can be used to represent ID values. However, an ID value is always serialized as a string. For example, assume the following object is returned by a GraphQL resolver:

{
  id: 123, // ID
  name: "Alice", // String
}

Then, the client will observe the following JSON object:

{
  "id": "123",
  "name": "Alice"
}

Type definition generators have to deal with this asymmetry, but nitrogql did not until this release. In 1.5, ID was always treated as string in TypeScript. This was too restrictive than necessary.

In 1.6, ID has the following default mapping:

ID:
  send: string | number
  receive: string

This post will explain what this means and how to customize scalar type mappings in nitrogql 1.6.

Four different usage of GraphQL types

In a GraphQL application written in TypeScript, each GraphQL type can be used in four different ways, two of which are on the server side and the other two are on the client side.

Resolver input position

The first usage is in the input position of a resolver. Consider the following GraphQL schema:

type Query {
  user(id: ID!): User!
}

Then, the implementation of the user resolver will look like this:

const userResolver: Resolvers<Context>["Query"]["user"] = async (
  _,
  { id },
  // ^ `id` used in the resolver input position
) => {
  // ...
}

In the above code, id is used in the input position of the resolver. In this case the type of id is string regardless of whether the client sends an integer or a string. This is because the GraphQL server applies coercion to the input values before the resolver is called.

Resolver output position

The second usage is in the output position of a resolver. It is illustrated by the following example:

const userResolver: Resolvers<Context>["Query"]["user"] = async (
  _,
  { id },
) => {
  // ...
  return {
    id: user.id,
  // ^ `id` used in the resolver output position
    name: user.name,
  };
}

Assuming that the id field of the User type is ID, the type of user.id can be string | number. This value is then serialized as a string when it is sent to the client.

Operation input position

The third usage is in the input position of an operation. Assume you want to run the following GraphQL operation:

query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
  }
}

Then, typical client-side code will look like this:

const { data } = await client.query({
  query: GetUserQuery,
  variables: {
    id: "123",
  // ^ `id` used in the operation input position
  },
});

In this case, you can pass either a string or a number to the id variable. The type of id is string | number. This value is then sent to the server as-is (without coercing it to a string).

🕵️‍♂️ Note that the client does not know about the schema in a normal setting. Therefore, the client cannot apply coercion to the variable values based on their types.

Operation output position

The fourth usage is in the output position of an operation. It is illustrated by the following example:

const { data } = await client.query({
  query: GetUserQuery,
});
// ...
const id = data.user.id;
//    ^ `id` used in the operation output position

In this case, the type of id is string. This is because the value is always serialized as a string before it is sent to the client.

Summary of the four usages

To summarize, each GraphQL type can be used in four different ways:

UsageLocationTypeScript type (ID)
Resolver inputServerstring
Resolver outputServerstring | number
Operation inputClientstring | number
Operation outputClientstring

Notice that you cannot categorize these as server/client or input/output, looking at the ID example.

Instead, nitrogql adopted the send/receive terminology to categorize these usage into two groups. The send group contains the “resolver output” and the “operation input” usages. The receive group contains the “resolver input” and the “operation output” usages.

These terminologies come from the fact that the “send” group is used where a value is being sent to the other side, and the “receive” group is used where a value is being received from the other side. Existing GraphQL servers behave such that values in the “receive” group are already coerced, while values in the “send” group are not.

At least this is the best fit for the ID type, which is why we adopted this terminology in nitrogql.

Customizing scalar type mappings

In nitrogql 1.6, you can specify different TypeScript types for each usage of a GraphQL scalar type.

nitrogql now supports three forms to specify scalar type mappings:single, send/receive and separate.

Single form

The single form is the simplest form. It is the same as the scalar type mapping in nitrogql 1.5. You can specify a single TypeScript type for all usages of a GraphQL scalar type. For example, the following configuration specifies that String is always treated as string:

String: string

All built-in scalar types except ID are configured in this form by default.

Send/receive form

The send/receive form allows you to specify different TypeScript types for the “send” group and the “receive” group. For example, the following configuration specifies that ID is treated as string | number in the “send” group and as string in the “receive” group:

ID:
  send: string | number
  receive: string

This is the default configuration for ID in nitrogql 1.6.

Separate form

The separate form allows you to specify different TypeScript types for each usage. For example, the mapping for ID could be specified as follows in this form:

ID:
  resolverInput: string
  resolverOutput: string | number
  operationInput: string | number
  operationOutput: string

This form exists for completeness, but we have not found a use case for it yet. If you have a use case for this form, please let us know!

Notes on GraphQL code generator compatibility

If you are using GraphQL code generator, you might know that it has a similar feature which allows you to specify different TypeScript types for different situations. Namely, it allows you to specify an inputtype and an output type for each GraphQL scalar type. For example, the mapping for ID could be specified as follows in GraphQL code generator:

# GraphQL code generator config
ID:
  input: string
  output: string | number

However, this input/output semantics is different from the send/receive semantics in nitrogql. They are summarized in the following table:

UsageLocationGraphQL code generatornitrogql
resolverInputserverinputreceive
resolverOutputserveroutputsend
operationInputclientinputsend
operationOutputclientoutputreceive

This divergence is intentional. Our investigation shows that the send/receive semantics better reflects the actual behavior of GraphQL servers. We avoided using the same terminology as GraphQL code generator (input/output) and chose to invent our own (send/receive) instead.

Conclusion

nitrogql 1.6 allows you to specify different TypeScript types for different usages of a GraphQL scalar type. This allows you to use the ID type in a more convenient way.

While GraphQL Code Generator already has a similar feature, the semantics is different. We chose to invent our own terminology to better reflect the reality.


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