Custom scalars
How to implement custom scalars for your GraphQL API
To view this content, buy the book! 馃槂馃檹
Or if you鈥檝e already purchased.
Custom scalars
If you鈥檙e jumping in here,
git checkout 6_0.2.0
(tag 6_0.2.0, or compare 6...7)
In the last section we mentioned that the ID
scalar is serialized like a string, but what does that process look like, and how do we make our own scalars? The only built-in scalars are Int
, Float
, String
, Boolean
, and ID
. Another scalar type that most apps use is a date. For example, it would be nice to have a Review.createdAt
. We could make it an Int
, but then is it seconds or milliseconds since the [Unix epoch](https://en.wikipedia.org/wiki/Epoch_(computing\))? Or it could be a String
, but there are a lot of string date formats out there. And both ways are missing validation (testing whether the string is a valid date string) and the improved understanding that comes from being able to know, looking at the schema, which fields are meant to be dates. So let鈥檚 make our own Date
scalar. We can add it to our schema:
scalar Date
type Query {
hello: String!
isoString(date: Date!): String!
}
#import 'Review.graphql'
#import 'User.graphql'
type Review {
id: ID!
text: String!
stars: Int
fullReview: String!
createdAt: Date!
updatedAt: Date!
}
...
First we declare the new scalar type (scalar Date
), and then we use it for a new isoString
query as well as createdAt
and updatedAt
fields on Review
. We make them non-nullable because all Review objects will have them.
We can use the word
Date
for our type because we don鈥檛 have other types of dates or times in our app. If we also had aDate
that had no time component, like a birthday, or aTime
that had no date component, like 14:00 (2 p.m.), we could call our new scalarDateTime
.
isoString
takes a Date
as an argument and returns the date formatted as a string in the ISO format:
const resolvers = {
Query: {
hello: () => '馃實馃審馃寧',
isoString: (_, { date }) => date.toISOString()
}
}
Next we add to our resolvers a GraphQLScalarType
, which tells Apollo Server how to handle a custom scalar. It will look like this:
import { GraphQLScalarType } from 'graphql'
export default {
Date: new GraphQLScalarType({
name:
description:
parseValue(value) {}
parseLiteral(ast) {}
serialize(date) {}
})
}
GraphQLScalarType
takes five parameters:
name
matches the scalar name we added to the schema, so'Date'
description
is shown in the schema section of GraphiQL and Playground. It says what the scalar represents and how it appears in the JSON response from a server. The built-in description forID
, for instance, is:
The
ID
scalar type represents a unique identifier, often used to refetch an object or as a key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as"4"
) or integer (such as4
) input value will be accepted as an ID.
parseValue(value)
is a function called when the server receives a query variable for a Date argument. The variable鈥檚 value is passed toparseValue()
, and the function should return the value in our desired format鈥攊n this case, a JavaScript Date object. For example, if the client sends this query:
query ISOString($date: Date!) {
isoString(date: $date)
}
with this as the variables JSON:
{
"date": 1442188800000
}
then parseValue
is passed the integer 1442188800000
and should return a JS Date object, which Apollo Server will provide to our resolver, which calls .toISOString()
on the JS Date object:
isoString: (_, { date }) => date.toISOString()
parseLiteral(ast)
is called when the server receives a query with a literal argument鈥攎eaning the argument is written in the query document itself instead of being provided separately in JSON (as variables are).ast
stands for abstract syntax tree, which is an object that Apollo Server uses to parse the query document.ast.value
has the literal value, and is always a string. Similar toparseValue()
,parseLiteral()
should return the server鈥檚 internal representation of the scalar type. If the client sends this query document:
{
isoString(date: 1442188800000)
}
Then parseLiteral(ast)
will be called, and ast.value
will be "1442188800000"
.
serialize(date)
is called when the server is formatting a JSON response for the client. A resolver returns a JS Date object, then Apollo Server callsserialize()
with that object, andserialize()
returns the date in a format that can be put into the JSON response鈥攚hich in our implementation of theDate
scalar is an integer. For example, if theReview.createdAt
resolver returns a JS Date, we would see an integer in the response:
If you're following along, this query won't work until we fill in
Date.js
and add it tosrc/resolvers/index.js
.
Here鈥檚 a basic implementation of the above:
import { GraphQLScalarType } from 'graphql'
export default {
Date: new GraphQLScalarType({
name: 'Date',
description: `The \`Date\` scalar type represents a single moment in time.
It is serialized as an integer, equal to the number of milliseconds since
the Unix epoch.`,
parseValue: value => new Date(value),
parseLiteral: ast => new Date(parseInt(ast.value)),
serialize: date => date.getTime()
})
}
parseValue()
takes the integer and creates a Date
. parseLiteral()
gets the ast.value
string, converts it into an integer, and creates a Date
. serialize()
takes the date and returns the milliseconds since epoch.
One important aspect of defining a custom scalar that we鈥檙e missing is validation. If we check the values we鈥檙e getting and throw errors with descriptive messages, it will help people using our API. Let鈥檚 do that:
import { GraphQLScalarType } from 'graphql'
import { Kind } from 'graphql/language'
const isValid = date => !isNaN(date.getTime())
export default {
Date: new GraphQLScalarType({
name: 'Date',
description:
`The \`Date\` scalar type represents a single moment in time. It is serialized as an integer, equal to the number of milliseconds since the Unix epoch.',
parseValue(value) {
if (!Number.isInteger(value)) {
throw new Error('Date values must be integers')
}
const date = new Date(value)
if (!isValid(date)) {
throw new Error('Invalid Date value')
}
return date
},
parseLiteral(ast) {
if (ast.kind !== Kind.INT) {
throw new Error('Date literals must be integers')
}
const date = new Date(parseInt(ast.value))
if (!isValid(date)) {
throw new Error('Invalid Date literal')
}
return date
},
serialize(date) {
if (!(date instanceof Date)) {
throw new Error(
'Resolvers for Date scalars must return JavaScript Date objects'
)
}
if (!isValid(date)) {
throw new Error('Invalid Date scalar')
}
return date.getTime()
}
})
}
In parseValue()
and parseLiteral()
, we check whether the client sent an integer, then we create a JS Date and check whether it鈥檚 valid. In serialize()
we check that the value returned from a resolver is a JS Date object, then we check if it鈥檚 a valid date, and finally we return the milliseconds since epoch.
We add this file to our resolvers in resolvers/index.js
by importing and adding to our resolversByType
array:
...
import Review from './Review'
import User from './User'
import Date from './Date'
export default [resolvers, Review, User, Date]
We saw our isoString
query working above, but now if we make a mistake, we get a helpful error message:
The last part of our schema change for which we have to implement resolvers is Review
鈥檚 createdAt
and updatedAt
. In MongoDB, the creation time is included in the default ID format, ObjectId. The first 4 bytes are the seconds since Unix epoch, so we can get the creation time from that. (And since it鈥檚 the first 4 bytes, we can also sort by an ObjectId to order by most/least recently created.) The mongodb
node library provides a method ObjectId.getTimestamp()
that extracts the date for us:
export default {
Query: ...
Review: {
...
createdAt: review => review._id.getTimestamp()
},
Mutation: ...
}
updatedAt
is a field that we鈥檒l have to store in the database when reviews are created and update when reviews are modified. We don鈥檛 have a way of modifying reviews yet, so we鈥檒l just add a line to our creation method:
import { MongoDataSource } from 'apollo-datasource-mongodb'
export default class Reviews extends MongoDataSource {
...
create(review) {
review.updatedAt = new Date()
this.collection.insertOne(review)
return review
}
}
Now we can include updatedAt
in our reviews
query, but we get the error Cannot return null for non-nullable field Review.updatedAt
:
Apollo Server is telling us that it can鈥檛 return null
for Review.updatedAt
to the client because the schema says it鈥檚 a non-nullable field. Why is it trying to return null
for Review.updatedAt
? It鈥檚 not鈥攐ur resolver is. Our reviews
resolver is returning reviews fetched from the database, but none of them have an updatedAt
property because they were inserted before we updated our Reviews.create()
data source method. We could fix our reviews in the database by adding an updatedAt
field, but let鈥檚 just delete them and re-create. If you鈥檇 like a GUI (Graphical User Interface, i.e., a program that runs in its own window instead of in the command line) for interacting with MongoDB, we recommend MongoDB Compass. Here鈥檚 how to delete all of our reviews using the mongo
command-line shell:
$ mongo
MongoDB shell version v4.0.3
connecting to: mongodb://127.0.0.1:27017
...
> use guide
switched to db guide
> db.reviews.find({})
{ "_id" : ObjectId("5cdfb1946df8548efb438535"), "text" : "Passing", "stars" : 3 }
{ "_id" : ObjectId("5cdfb1e4a1cf288f4d86dced"), "text" : "Passing", "stars" : 3 }
{ "_id" : ObjectId("5cdfb28e48435b90119bd2c6"), "text" : "Passing", "stars" : 3 }
> db.reviews.remove({})
WriteResult({ "nRemoved" : 3 })
> db.reviews.find({})
> exit
bye
Our second call to db.reviews.find({})
doesn鈥檛 show results because the collection is now empty. And when we do our reviews
query, we get back an empty array. Now if we use Playground to send a createReview
mutation, then we can do a reviews
query with the createdAt
and updatedAt
fields:
The last three digits of createdAt
will always be 000
because the API returns milliseconds since Epoch, and all that鈥檚 stored in the ObjectId is seconds since Epoch.
An alternative to clearing the database collection would have been to add a resolver for Review.updatedAt
that returns Review.createdAt
when there鈥檚 no updatedAt
property on the review object. In order to call another resolver, we鈥檇 need to name the resolver鈥檚 object and move export default
to the end:
const resolvers = {
Query: {
reviews: ...
},
Review: {
id: ...
fullReview: ...
createdAt: review => review._id.getTimestamp(),
updatedAt: review => review.updatedAt || resolvers.Review.createdAt(review)
},
Mutation: {
createReview: ...
}
}
export default resolvers
Then we could reference another resolver function (resolvers.Review.createdAt(review)
).
In this section we created a new Date
scalar type, added Query.isoString
, which has a Date
argument, and Review.createdAt
and Review.updatedAt
, which resolve to Date
s. We鈥檒l continue to use the Date
type in the rest of our app, for instance for User.createdAt/updatedAt
in the next section.