lee.david.cs

Best GraphQL Stack for a TypeScript Dev

February 19, 2021

Disclaimer: This post reflects my personal experience as a TypeScript developer.

If you're looking to start a GraphQL project using Node.js, I would recommend using Urql, Nexus, Prisma and GraphQL Codegen.

Obviously, depending on your project and personal preferences, other tools might make more sense; however, I can at least attest to my own developer experience (DX) with the stack above.

Nexus + Prisma

If you've gone through the same GraphQL journey as I have: originally defining your schema and resolvers directly with graphql-js and eventually taking the SDL approach offered by graphql-tools; then you've likely also experienced the same pain-points as I have:

  1. Lackluster TypeScript support
  2. Overly verbose schema definition (in the case of graphql-js)
  3. Separate maintenance of the SDL from the resolvers in large schemas

Similarly, if you've worked with sequelize or mongoose, you've likely also dealt with the pains of poor TypeScript support, especially when querying for nested entities in your databases.

Thankfully, Nexus + Prisma were built with all of these considerations in mind.

  1. With advanced use of type generics and code-generation, both have first-class support for TypeScript.
  2. Nexus's API aims to emulate the feel of writing the GraphQL SDL through code, which allows us to cut down on overly verbose schema definitions.
  3. Nexus's code-first approach puts the schema definition and resolvers in the same place in code.

More benefits can be found on the Nexus + Prisma websites

Nexus + Prisma examples

Example of defining your database models with prisma

1datasource db {
2 provider = "postgresql"
3 url = env("DATABASE_URL")
4}
5
6generator client {
7 provider = "prisma-client-js"
8}
9
10enum UserAllegiance {
11 NEUTRAL
12 ALLIANCE
13 HORDE
14}
15
16model User {
17 id Int @id @default(autoincrement())
18 email String @unique
19 firstName String
20 lastName String
21 // Example of optional field
22 imageUrl String?
23 alliance UserAllegiance @default(NEUTRAL)
24 // Relation example
25 posts Post[]
26 createdAt DateTime @default(now())
27 updatedAt DateTime @updatedAt
28
29 // Example of compound index constraint
30 @@unique([firstName, lastName])
31}
32
33model Post {
34 id Int @id @default(autoincrement())
35 title String
36 content String
37 // Relation example
38 author User @relation(fields: [authorId], references: [id])
39 authorId Int
40}

Example of GraphQL with nexus

1import { GraphQLSchema } from "graphql";
2import { arg, makeSchema, mutationType, objectType, queryType } from "nexus";
3import { nexusPrisma } from "nexus-plugin-prisma";
4
5const User = objectType({
6 name: "User",
7 description: "A user of our application",
8 definition: (t) => {
9 // t.model auto-generates the types/resolvers from our database
10 t.model("id");
11 t.model("email");
12 t.model("firstName");
13 t.model("lastName");
14 t.model("imageUrl");
15 t.model("alliance");
16 t.model("posts");
17 t.model("createdAt");
18 t.model("updatedAt");
19 t.nonNull.string("fullName", {
20 resolve: (parent, args, ctx, info) => {
21 // parent, args and ctx are all extensively typed
22 const { firstName, lastName } = parent;
23
24 return [firstName, lastName].join(" ");
25 }
26 });
27 t.nonNull.number("countPosts", {
28 resolve: async (parent, args, ctx, info) => {
29 const { id } = parent;
30 const { prisma } = ctx;
31
32 return await prisma.post.count({ where: { authorId: id } });
33 }
34 });
35 }
36});
37
38const Post = objectType({
39 name: "Post",
40 description: "Comment/Post, made by a User",
41 definition: (t) => {
42 t.model("id");
43 t.model("title");
44 t.model("content");
45 t.model("author");
46 t.model("authorId");
47 }
48});
49
50const Query = queryType({
51 description: "Root query type",
52 definition: (t) => {
53 t.nonNull.boolean("ok", { resolve: () => true });
54 // t.crud auto-generates types/resolvers for queries/mutations
55 t.crud.user();
56 t.crud.users();
57 t.crud.post();
58 t.crud.posts();
59 // You can also define custom resolvers
60 t.nonNull.number("countPosts", {
61 args: {
62 // PostWhereInput is generated by t.crud.posts()
63 where: arg({ type: "PostWhereInput" })
64 },
65 resolve: async (parent, args, ctx, info) => {
66 // parent, args and ctx are all extensively typed
67 const { where } = args;
68 const { prisma } = ctx;
69
70 return await prisma.post.count({ where });
71 }
72 });
73 }
74});
75
76const Mutation = mutationType({
77 description: "Root mutation type",
78 definition: (t) => {
79 t.nonNull.boolean("ok", { resolve: () => true });
80 t.crud.createOneUser();
81 t.crud.deleteOneUser();
82 t.crud.updateOneUser();
83 t.crud.upsertOneUser();
84 t.crud.createOnePost();
85 t.crud.deleteOnePost();
86 t.crud.updateOnePost();
87 t.crud.upsertOnePost();
88 }
89});
90
91// This is just a GraphQLSchema type; use any gql server solution you like
92export const schema: GraphQLSchema = makeSchema({
93 shouldGenerateArtifacts: true,
94 shouldExitAfterGenerateArtifacts: false,
95 types: { User, Post, Query, Mutation },
96 outputs: {
97 schema: "schemaPath",
98 typegen: "typingsPath"
99 },
100 plugins: [
101 nexusPrisma({
102 experimentalCrud: true,
103 outputs: "prismaTypingsPath",
104 paginationStrategy: "prisma",
105 prismaClient: (ctx) => ctx.prisma
106 })
107 ],
108 sourceTypes: {
109 headers: [],
110 modules: []
111 },
112 contextType: {
113 module: "@myapp/server/graphql/context",
114 export: "GraphQLServerContext",
115 alias: "ctx"
116 }
117});

More docs on the Nexus and Prisma websites

1import { context, GraphQLServerContext } from "@myapp/server/graphql/context";
2import { schema } from "@myapp/server/graphql/schema";
3import { ApolloServer } from "apollo-server-micro";
4
5export const apolloServer = new ApolloServer({
6 context,
7 schema
8});

GraphQL on the client

For Toastel, I've actually used ApolloClient for my client-side GraphQL needs. However, after having read the docs and trying Urql, I regret not having started with Urql instead.

ApolloClient, along with several custom links, occupies between 30 - 40kB gzipped on my client bundle, compared to Urql's 10 - 20kB.

From an API perspective, Urql also feels remarkably similar to ApolloClient in all of: the GraphQL client, React hooks and caching.

Setting up authentication, retries and caching also felt easier with exchanges and Urql's extensive documentation, whereas Apollo's felt lacking in some places.

Urql provides more comparisons between it and Apollo/Relay on their site.

Typed client-side queries

As we've already improved our TypeScript DX on the server, we might as well do the same on the client.

Let's say that we have a component that renders a list of posts.

1import { Post } from "@prisma/client";
2import React, { FC } from "react";
3import { useQuery } from "urql";
4
5export interface PostListProps {
6 userId: number;
7}
8
9export const PostList: FC<PostListProps> = ({ userId }) => {
10 const [result] = useQuery<{ posts: Post[] }>({
11 query: `
12 query GetPosts($userId: Int!) {
13 posts(where: { id: $userId }) {
14 id
15 title
16 }
17 }
18 `,
19 variables: { userId }
20 });
21
22 const { data, fetching, error } = result;
23
24 if (fetching) return <p>Loading...</p>;
25 if (error) return <p>Oh no... {error.message}</p>;
26 if (!data) return <p>Something went wrong</p>;
27
28 return (
29 <ul>
30 {data.posts.map((post) => (
31 <li key={post.id}>
32 <span>{post.title}</span>
33 {/* Oh no! We don't have content, but we passed static analysis! */}
34 <span>{post.content}</span>
35 </li>
36 ))}
37 </ul>
38 );
39};

In the code example above, the type we passed to useQuery does not match the query string. So we not only had to define our useQuery type, but also static analysis failed to catch the error we left in.

We can resolve this by using GraphQL Codegen.

1# codegen.yml file
2
3overwrite: true
4schema:
5 - graphqlSchemaPathHere
6documents: "./src/client/graphql/**/*.{fragment,mutation,query}.{ts,graphql}"
7generates:
8 ./src/client/graphql/generated/graphql.tsx
9 plugins:
10 - time:
11 message: "This file was generated on: "
12 format: MMM Do YYYY h:mm:ss a
13 - typescript
14 - typescript-operations
15 - typescript-urql
16 ./src/client/graphql/generated/schema.gen.graphql
17 plugins:
18 - time:
19 - schema-ast:
20 commentDescriptions: true
1# get-posts.query.graphql file
2
3query GetPosts($userId: Int!) {
4 posts(where: { id: $userId }) {
5 id
6 title
7 }
8}
1/* ... */
2
3export const PostList: FC<PostListProps> = ({ userId }) => {
4 /* Now we'll catch that we're missing a property, without needing to maintain typedefs */
5 const [result] = useGetPostsQuery({ variables: { userId } });
6
7/* ... */

With this, not only have we improved our TypeScript DX on the server, but we also achieved a similar DX on the client with an API much like ApolloClient at a fraction of the added bundle size.

Happy coding! -- David Lee