Typescript and GraphQL: Custom Scalar with Apollo Client
Posted on: 2022-04-19
Imagine you must interact with a system that provides 64 bits type, like a long
in Java by GraphQL. The GraphQL standard does not have a number for 64 bits.
Relying on number cause issue with a number above 2^53 because of how JavaScript handles number. Hence, above that size, in JavaScript you must create a wrapped type called BigInt
. While this article focuses on Long
to BigInt
, it is the same issue with many other types like Date
, Time
or more complex scalar.
Server Configuration
There is a part that you will need to communicate with the GraphQL server owner. The server cannot send the GraphQL Int
type, so the server must send the data as a string
. Sending the value as a Java Long
would create an accuracy issue. The transmission of the data will be a number
and causes an issue if the value is above 2^53 bit. The best is to have the server marshaling the value into a string
. Hence, inside the string
, the value can be above 2^53. It means the value would be in the format:
data : {
YourEntityName: {
myField: "192381290381209381290839012830918209510257182759817287128371298738928"
}
}
To specify to the client that the string
is actually not a GraphQL string
, the best is to rely on a custom scalar. For example, the GraphQL server can create a GraphQL Long
time.
scalar Long
Therefore, the client knows that type is a Long
, the data is transmitted as a string, and we can tackle the next step with these two pieces of information.
Client Configuration
On the client-side, there is some work to do. First, I assume you are using TypeScript and Codegen to generate your TypeScript from the GraphQL files.
I assume you have somehow the whole Graphql Schema and several files in your client repository that have queries, mutations, inputs, and types. Something like:
schema: "output/introspectionResult.json"
documents: "./src/**/*.graphql"
overwrite: true
generates:
./src/autogenerated/allTypes.ts:
config:
maybeValue: T | undefined
preResolveTypes: true
avoidOptionals: true
plugins:
- typescript
- typescript-operations
So, you need to add a couple of details to tell Codegen how to generate the proper type for the custom scalar Long
and to tell Codegen how to convert the string
into a BigInt`.
TypeScript: Converting the Custom Scalar to Your Type
Inside your codegen.yml you can define a scalars
option. For a Long
to a BigInt
you can have:
scalars:
Long: BigInt
If you execute the generation, you will see that anywhere in your queries that you are fetching a type of Long
that the TypeScript generated will have the type BigInt
. So, the codegen.yml file looks like this:
schema: "output/introspectionResult.json"
documents: "./src/**/*.graphql"
overwrite: true
generates:
./src/autogenerated/allTypes.ts:
config:
maybeValue: T | undefined
preResolveTypes: true
avoidOptionals: true
scalars:
Long: BitInt
plugins:
- typescript
- typescript-operations
However, the work is only for design time so far. At runtime, the typeof
is still a string
.
Apollo Client: Converting Value to the Proper Type
The second part is the trickiest. It requires having a custom plugin for Codegen. The plugin's goal is to generate a type policy for the Apollo Client. On every GraphQL request, Apollo will inject the response into the InMemoryCache
. There is a way with the Apollo Client to have custom logic when data enters the cache. The plugin's goal is to automatically generate these functions for all the fields from your custom scalar. Codegen is the best to know since it analyzes the schema and knows which field is being used in your GraphQL files. Thus, know which field to create the custom policy.
Before diving into the plugin, let's see the typePolicies
property of Apollo Client (version 3).
const cache = new InMemoryCache({
typePolicies: {
YourEntityName: {
fields: {
myField: longBigIntPolicy
},
},
},
});
The example shows that add manually a function called longBigIntPolicy
for the YourEntityName.field
that is of type Long
. While you can do that case by case, a plugin would allow you to handle it for you systematically and have the following code instead.
const cache = new InMemoryCache({
typePolicies: {
...scalarTypePolicies,
// You can still define here custom policies
}
});
Codegen: Plugin to Generate the Type Policies
The codegen.yml needs the last modification to call a plugin to generate the type policies for Apollo to consume.
First, a configuration for the plugin that maps the scalarTypePolicies
types. In that example, there is one type: Long
and it is mapped to longBigIntPolicy#longBigIntPolicy
which mean check in the longBigIntPolicy
file for the function longBigIntPolicy
.
Second, there is the plugin that must be a .js
file. Here it points to generateScalarPolicies.js
schema: "output/introspectionResult.json"
documents: "./src/**/*.graphql"
overwrite: true
generates:
./src/autogenerated/allTypes.ts:
config:
maybeValue: T | undefined
preResolveTypes: true
avoidOptionals: true
scalars:
Long: BitInt
scalarTypePolicies:
Long: "../scalarPolicies/longBigIntPolicy#longBigIntPolicy"
plugins:
- ./plugins/output/generateScalarPolicies.js
- typescript
- typescript-operations
The tricky part is the plugin. We need something that scans all your types against the server's GraphQL schema and identifies where to create an entry for each field. The good news is that there is an unsupported repository with the plugin, and it is written in TypeScript! The bad news is that the NPM is not usable, and the project is not supported. So, we need to copy the code and have a script to run the TypeScript into a JavaScript file before executing the Codegen. You can create a bash script that runs TypeScript and use it before one NPM command you have for your project.
#Content of ./plugins/generate.sh
npx tsc plugins/generateScalarPolicies.ts --target es2018 --module commonjs --moduleResolution node --lib esnext --skipLibCheck true --outDir "./plugings/output/"
Inside your package.json:
"scripts": {
"gen": "./plugins/generate.sh && graphql-codegen --config codegen.yml"
}
The result is that along with your typical generated types, inside allTypes.ts
(as defined in the codegen.yml
file), you will have a const
variable.
export const scalarTypePolicies = {
// ...
}
The constant is the one you need to refer to with Apollo.
Execution
At that point, you can execute your code and see that the type defined as BigInt
is also the one at runtime. If you are using typeof
on the field that was defined to be Long
in the GraphQL schema, it is not a number
, nor a string
-- it is a BigInt
. Exactly what we desire.
Conclusion
Using custom scalar in Apollo Client is very cumbersome. The documentation is scarce, and few solutions are wrong. For example, going with the Apollo Link route will cause your serialization issue once the data gets into the InMemoryCache
. Furthermore, that solution requires having the whole schema on the client (browser), which could be impractical for a vast schema. The Codegen solution is elegant because it generates a tailored version of the conversion for your usage and executes once at design time. However, because it requires an unsupported plugin, it raises the question of how well scalar types are supported. A fundamental issue for a system that is based on type. If you look at the problem from the perspective of Apollo, we can see that they are pushed toward a wall: they are in the business of the runtime while the solution must be performed at design time to be efficient. Hence, it must be why issues have risen for years in their Github without any concrete explanation. On the other side, Codegen has a few Apollo official plugins, but none that perform a type-to-type conversion. Codegen has a Apollo Client type policies helper, but it is limited to typing the whole policy instead of working on type conversion.
After years of GraphQL standard supporting custom scalar, my final words are that we must still rely on custom code to handle the browser (with Apollo) custom scalar type. That is the reality.