This article will look at one of the most frequent issues that arise when using Typescript and MongoDB to build a GraphQL-based backend. Before we get started, let's have a look at the primary technologies we'll be dealing with.
The article assumes that you have a working knowledge of TypeScript, MongoDB, GraphQL and Node.js. Before you begin, you will need:
Most of the production level code these days is written in Typescript owing to its phenomenal type system. Adding GraphQL to this, we can leverage all its benefits such as having a client-driven API, preventing any over-fetching or under fetching of data, reducing the number of API calls, static typing etc. However, the initial boilerplate code quickly increases when a database comes into the mix. You’ll quickly find yourself maintaining three type definitions for one schema which then creates multiple sources of truth.
The following example shows a Mongoose schema, an Interface (type definition) for the schema, and the corresponding GraphQL type definition
// ================ User.ts ================// Typescript Interface Definitionexport interface IUser {name: string;email: string;username: string;password: string;articles: string[];}// MongoDB, Mongoose Schema definitionconst useSchema = new Schema({name: {type: String,required: true,},email: {type: String,required: true,},username: {type: String,required: false,unique,default: generateUserName(),},password: {type: String,required: true,},articles: [{type: Schema.Types.ObjectId,required: false,},],});export const UserModel = mongoose.model('User', userSchema);// GraphQL Type Definition (either one works)export const types = gql`type User {id: IDname: Stringemail: StringuserName: Stringpassword: Stringarticles: [Article]}`;
By single source of truth, what I mean is, it would be really great if we could have a single definition for each schema. A single file that needs to be changed to make any updates in the future. To maintain a single source of truth, there exist multiple libraries such as TypeGraphQL, GraphQL Nexus, TypeORM, Typegoose etc. In this guide, we’ll be using TypeGraphQL as GraphQL Nexus is not well maintained. TypeORM is well suited for Relational Databases and it has some compatibility issues with MongoDB which makes Typegoose as our choice.
yarn add type-graphql reflect-metadata
import “reflect-metadata”
yarn add typegoose
{"compileOptions": {"emitDecoratorMetadata": true,"experimentalDecorators": true,}
{"target": "es2018" // or newer if your node.js version supports this}
For optional configurations, you can check out the installation page of TypeGraphQL here.
Following is the definition that takes care of all the three types/schemas i.e. Mongoose Schema, Typescript interface and GraphQL type definition.
// ================ UserType.ts ================// Librariesimport {Field, ObjectType} from 'type-graphql';import {prop as Property, getModelForClass} from '@typegoose/typegoose';import {ObjectId} from 'mongodb';// Modelsimport {ArticleModel} from './Article.ts';@ObjectType({description: 'The User Model'})export class User {@Field(type => ID)readonly _id: ObjectId;@Property({required: true})@Field({description: 'Name of the user'})name: string;@Property({required: true, unique: true})@Field()email: string;@Property({required: false, default: generateUserName()})@Field({nullable: true})username?: string;@Property({required: true})password: string;@Property({required: false, default: []})@Field(type => [string], {name: 'articleIds'})articles: string[];@Field(type => [Article])get articles(): Article[] {return this.articles.map(async articleId => await ArticleModel.findById(articleId),);}}export const UserModel = getModelForClass(User);
What exactly is going on here? That might be your question. Worry not! At first, this syntax feels a bit janky and uneasy but once you get to know what each line of code here represents, it becomes easy to understand the code.
TypeGraphQL uses classes and decorators for the definition of the types. The main reason for doing so is that Typescript has interfaces that are nothing but classes. You can find more about decorators here.
That covers most of the ways how we'll define our types. It's just one class that takes care of MongoDB Schema, Typescript interface, GraphQL Type. You can create a similar ObjectType for Article as well. Once types are defined the next steps are Queries and Mutations. Following is an example of how we are going to define them.
// ================ UserResolver.ts ================// Librariesimport {Resolver,Query,Arg,Ctx,Mutation,InputType} from 'type-graphql';// Typesimport { UserType, UserModel } from './User';// Input Type@InputType()export class userInputType {@Field()name: string;@Field()email: string;@Field({nullable: true})username?: string@Field()password: string}@Resolver((of) => UserType)export class UserResolver {@Query((returns) => [User], { description: "Returns an array of all Users" })async getUsers(): Promise<User[]> {return await UserModel.find({});}@Query((returns) => User, { nullable: true })async getUserById(@Args('id') id: string): Promise<User | undefined> {return await UserModel.findById(id)}@Mutation((returns) => User)async addUser(@Arg("name") name: string,@Arg("email") email: string,@Arg("username") username?: string,@Arg("password") password: string,): Promise<User | Error> {const existingUser = await UserModel.find({email})if (existingUser) throw new Error('User already Exists')const newUser = new UserModel({name,email,username,password: hashPassword(password),articles: []})return await newUser.save();}@Mutation((returns) => User)async updateUser(@Arg("user") userInput: userInputType@Ctx() ctx: Context,): Promise<User | Error> {if (!ctx.user) throw new Error("User not authorized.")return await UserModel.findOneAndUpdate({email}, userInput)}@Mutation((returns) => boolean)async deleteUser(@Arg("id") id: ID,@Ctx() ctx: Context,): Promise<boolean | Error> {if (!ctx.user) throw new Error("User not authorized")await UserModel.findByIdAndDelete(id)return true,}}
Here we go again! But this time you might be having some insights as to what is happening. Lets get to the breakdown.
getUserById(@Args('id')id: string,): Promise<User | undefined>
Now we have our entire schema, types, mutations and queries all set up. The final step is to build the graphql schema and configure apollo-server.
import {ApolloServer} from 'apollo-server';import {buildSchema} from 'type-graphql';import {UserResolver} from './schema/UserResolver';const init = async () => {/* Code to initialize MongoDB */// Build type-graphql executable schemaconst schema = await buildSchema({resolvers: [UserResolver],});// Initialize Apollo Serverconst server = new ApolloServer({schema});// Listen to the serverconst {url} = await server.listen(4000);console.log(`Server is running, GraphQL playground available at ${url}`);};init();
While TypeGraphQL has a rather steep learning curve and can look janky in the beginning, I assure you that maintaining the project, in the long run, is going to be effortless. Having a single source of truth also avoids code repetition and moreover prevents any sort of error while developing. TypeGraphQL along with a supporting library works like a charm. One thing to note, though the article showcases the benefits of using TypeGraphQL and Typegoose together, it doesn't mean that you can't use them separately. Depending upon your requirements, you may use either of the tools or a combination of them. (Another popular library is TypeORM)
The article covers a relatively basic setup with all CRUD operations. You can find advanced setup and configurations for production-level projects. Following are the links to the documentation of the various technologies used in this article.
Type-GraphQL Typegoose Typescript GraphQL
You can find the referenced code at this repo