DEV Community

Cover image for Single source of truth with Typescript, GraphQL and MongoDB
mouad sidqi
mouad sidqi

Posted on

Single source of truth with Typescript, GraphQL and MongoDB

Introduction

Typescript is great, and GraphQL is great. In this article I'm going to talk about a particular problem you might face when using both of them and how you can improve the synergy between the two when building a back-end system.
Alt Text

The Problem

When combining both GraphQL and Typescript, we can leverage the benefits of having a client-driven api, static typing, removing under and over-fetching issues (and much more). However we can quickly encounter an issue when we use them together, especially after introducing a database into the mix (MongoDB in our case). I'm talking about having multiple sources of truth.

What I mean by that is that we have to manage GraphQL schema (in SDL), Typescript types with interfaces, and MongoDB Schema. This can turn into a headache down the line when we need to change or add properties and fields to our system.

Let's see some examples to better understand this:

  • The following shows how we can define our Typescript interfaces and how to create models from a schema in MongoDB using mongoose.
// ------------Post------------
export interface Post {
    title: string;
    text: string;
}

const postSchema = new Schema({
    title: { type: String, required: true },
    text: { type: String, required: true }
});

export const PostModel = mongoose.model('Post', postSchema);

// ------------User------------
export interface User {
    _id?: string;
    name?: string;
    email?: string;
    password?: string;
    poss?: Post[] | string[];
}

const userSchema = new Schema({
    name: { type: String, required: true },
    email: { type: String, required: true },
    password: { type: String, required: true },
    posts: [{ type: ObjectId, ref: 'Post' }]
});

export const UserModel = mongoose.model('User', userSchema);
  • Next, we need to define GraphQL types.
export const types = gql`
  type Post {
    title: String!
    text: String!
  }
  type User {
    _id: ID
    name: String
    email: String
    password: String
    posts: [Post]
  }
`;

As you can see, If we decide to change "Post" type, say add Date property for example, we'd have to insert that in 3 separate places in our code.


The Solution

So how can we make this better ?

In order to group the sources of truth into a single component, we can make use of Typescript decorators and classes to define both our GraphQL types, queries, mutations AND MongoDB Schema.
There are multiple frameworks that offer this functionality. But for our use-case we'll use TypeGraphQL with Typegoose. Other options include the popular TypeORM, but I decided not to use it due to some compatibility issues with MongoDB. (read more about it in this excellent article by Elie Steinbock)


What are decorators ?

Decorators are a way to add annotations and a meta-programming syntax for class declarations and members, they make it possible to augment a class and its members as the class is defined.


TypeGraphQL requirements:

The framework requires few things: 

  • Install "reflect-metadata" package which, to put it simply, helps typescript get more details about types at run time. read more about reflection here.
npm install reflect-metadata --save
  • import it at the top of our entry file (before we use/import type-graphql or our resolvers):
import "reflect-metadata";
  • Decorators are a stage 2 proposal for JavaScript and are available as an experimental feature for TypeScript. This means that we need to enable the following options to true in tsconfig.json
"compilerOptions": {
  "emitDecoratorMetadata": true,
  "experimentalDecorators": true
}

Implementation

Take a look at the final result before we break it down:

@ObjectType()
export class Post {
    @Field()        @prop({ required: true })
    title!: string;

    @Field()        @prop({ required: true })
    text!: string;
}

@ObjectType()
export class User {
  @Field(type => ID, { nullable: true })
  _id?: string;

  @Field({ nullable: true }) @prop({ required: true })
  name?: string;

  @Field({ nullable: true }) @prop({ required: true })
  email?: string;

  @Field({ nullable: true }) @prop({ required: true })
  password?: string;

  @Field(type => [Post], { nullable: 'itemsAndList' }) @arrayProp({ itemsRef: 'Post' })
  posts?: Ref<Post>[];
}

Our components are now defined as a single class each. To define a component we use TypeGraphQL's @ObjectType decorator. @Field for each GraphQL field. Typegoose's @prop, @arrayProp (etc…) decorators to define a MongoDB Schema property.

Notes

  • When defining a property with simple types (i.e string, number, boolean, etc… ) we can use @Field(), however for more complex types we need to pass a callback that returns the type. The type can be either one of the predefined known GraphQL types or a Typescript class/interface/union etc... For example @Field(type => [Post]) is a field that returns an array of Posts.
  • Use Typegoose's Ref to save _ids of other objects as reference.
  • By default all TypeGraphQL fields are required unless you pass {nullable: true} as option to @Field() or you can make nullable the default by passing nullableByDefault: true option in buildSchema settings.

What about Queries and Mutations ?

@Resolver(of => User)
export class UserResolver {

  @Query(returns => [User], { nullable: "items" })
  @UseMiddleware(requireAdmin) // Guard middleware
  async users(): Promise<User[]> {
    return await UserModel.find();
  }

  @Mutation(returns => connection)
  async userLogin(
    @Args() { email, password }: userLoginArgs
  ): Promise<connection> {
    // .....................
  }

  // field resolvers would execute after a query/mutation
  @FieldResolver()
  async posts(@Root() root: any) {
    // root represents the User found from that query/mutation
    return await PostModel.find({ postedBy: root._id });
  }

  @FieldResolver()
  password() {
    return null;
  }
}

Resolvers are self-explanatory but here are some additional notes

Notes

  • Arguments can be defined each separately with @Arg() or combined in single object with @ArgsType() like userLoginArgs in the example above.
  • Properties that need to be resolved separately can have that done either with a @FieldResolver() in a @Resolver() class or simply with a @Field() in @ObjectType() class itself.
  • Use Middlewares and Authorization to control permissions and for additional features.

Conclusion

Pros

  • Single source of truth.
  • Easier to manage changes in the long run.
  • Can benefit from class-validation.
  • All the benefits of Typescript type checking system.

Cons

  • Typescript doesn't support splitting class definition into multiple files.
  • Might be less read-able if you're using decorators for the first time.
  • Due to using classes, it's much easier to have a circular dependency. Read this article if you find yourself in that situation.


Mouad Sidqi | sidqimouad0@gmail.com
Connect with me: Twitter | Linkedin

Top comments (2)

Collapse
 
chaolai profile image
Chao Lai

Hi Mouad, this is very helpful. Could you post the repo for this sample tutorial?

Collapse
 
msidqi profile image
mouad sidqi

The repo for the project is private unfortunately. I'm looking into the possibility of making a boilerplate for it