In a previous blog post, we reviewed the different approaches for building a GraphQL API: the standard, schema-first approach and a code-first approach using Nexus.
In this article we will explore another popular JavaScript code-first library: TypeGraphQL. We will use the same schema as before, the same in-memory data, and Apollo Server. This way we can easily compare how we can create the same API using different tools for building the schema.
Code-first in JavaScript with TypeGraphQL
Previously we used a simple eCommerce schema:
input BuyProductInput { count: Int = 1 productId: ID! } type Cart { id: Int items: [CartProduct]! } type CartProduct { count: Int product: Product } type Mutation { buyProduct(input: BuyProductInput!): Cart! } type Product { id: ID inStock: Boolean price: Int title: String } type Query { products: [Product]! }
The main idea behind TypeGraphQL is to define the schema using TypeScript classes and decorators.
If we have this simple class:
export class Product { id: string; title: string; price: number; }
Using decorators, we can instruct TypeGraphQL to generate the GraphQL Product Type:
import { ObjectType, Field, ID, Root, Int } from "type-graphql"; @ObjectType() export class Product { @Field((type) => ID) id: string; @Field() title: string; @Field((type) => Int) price: number; }
Any fields on the Product class that use the @Field
decorator will be added to the Schema, others will be ignored, so we always manage which fields are internal for the application and which ones will be added to the GraphQL Type. The @Field
decorator will infer the type, but sometimes we need to manually specify it like ID and Int above.
Once we have our types ready, let's add the Queries, Mutations, and FieldResolvers by defining classes and adding decorators:
@Resolver((of) => Product) export class ProductResolver { @FieldResolver() inStock(@Root() product: ProductEntity): boolean { return product.stock > 0; } @Query((returns) => [Product]) products(@Ctx() context: Context) { return context.db.products; } }
We can have as many resolver classes as we need and TypeGraphQL allows easy decoupling of business logic and services using Dependency Injection.
Similar to Nexus, we can extend types defined in a different module, favoring schema modularization.
//cart module @ObjectType() export class BaseCartProduct { @Field((type) => ID) count: number; } //product module @ObjectType() export class CartProduct extends BaseCartProduct { @Field((type) => Product) product(@Root() parent: CartProductEntity, @Ctx() ctx: Context) { return ctx.db.products.find( (value: ProductEntity) => value.id == parent.productId ); } }
We can also add a Mutation using the specific decorator.
@Resolver((of) => Cart) export class CartResolver { @Mutation((returns) => Cart) async buyProduct( @Arg("input") input: BuyProductInput, @Ctx() ctx: Context ): Promise<CartEntity> { return ctx.db.addToCart({ productId: input.productId, count: input.count!, customerId: ctx.customer.id, }); } @FieldResolver() items(@Root() cart: CartEntity) { return Array.from(cart.cartProducts.values()); } }
TypeORM Integration
TypeORM is one of the most mature NodeJS ORMs. Similar to TypeGraphQL it uses classes and decorators to bind our objects and our database.
import { ObjectType, Field, ID, Root, Int } from "type-graphql"; import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; @ObjectType() @Entity() export class Product { @Field((type) => ID) @PrimaryGeneratedColumn() id: string; @Field() @Column() title: string; @Field((type) => Int) @Column() price: number; }
In a single class, we have defined a GraphQL type and an entity. Using decorators, we can generate database columns, add to GraphQL types, or just have them as simple fields if no decorator is added.
Although this can increase developer productivity and help us build apps faster it needs to be used with care, as we're exposing our database structure to the GraphQL schema. Migrating a database column could break our schema and affect client applications, so developers need to be aware of this and create a proper migration strategy for the database.
Advanced Features
Authorization
TypeGraphQL also supports authorization as a first-class feature, also by using the @Authorized decorator. Let's see an example in our new stock field:
import { ObjectType, Field, ID, Root, Int, Authorized } from "type-graphql"; import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; @ObjectType() @Entity() export class Product { @Field((type) => ID) @PrimaryGeneratedColumn() id: string; @Field() @Column() title: string; @Field((type) => Int) @Column() price: number; @Field((type) => Int) @Column() @Authorized("ADMIN") stock: number; }
If a client makes a request without an Admin role an error will be returned. Similarly, we can add authorization to queries and mutations. We can add custom auth depending on our business logic, as in the authChecker example from the TypeGraphQL repo.
Validation
For validating arguments and inputs, we can rely on the GraphQL Scalars library. But sometimes, we need more validation logic in place, and we can easily integrate class-validator which, similar to TypeGraphQL and TypeORM, relies on decorators.
import { InputType, Field, ID, Int } from "type-graphql"; import { Min, Max } from "class-validator"; @InputType() export class BuyProductInput { @Field((type) => Int, { defaultValue: 1 }) @Min(1) @Max(10) count: number; @Field((type) => ID) productId: string; }
In this example, we are instructing TypeGraphQL to validate our count field to be between 1-10.
Final Notes
As with other code-first approaches, one downside is that it can be more difficult to understand the schema. This is especially the case for TypeGraphQL, since the schema is defined by decorated classes. To be effective, team-wide communication in the schema design process is crucial. The advantages of schema modularization, type safety, and code as a single source of truth for APIs may far outweigh the added communication overhead, especially for teams that are already comfortable using TypeScript and decorators.