Building an end-to-end typesafe API — without GraphQL

Colin McDonnell @colinhacks

published June 13th, 2021

In the mid-2010s, the pendulum of web development swung massively in the direction of increased type safety.

First, we all switched back to relational DBs after recovering from an embarrassingly long collective delusion that NoSQL was cool. Then TypeScript started its meteoric rise after years of quiet development. Shortly thereafter GraphQL was announced and quickly became the de facto way to implement typesafe APIs.

TLDR: I published a new library called tRPC that makes it easy to build end-to-end typesafe APIs without code generation, by leveraging the power of modern TypeScript. To jump straight to the tRPC walkthrough, click here.

GraphQL's TypeScript problem

GraphQL's biggest competitive advantage is that it's a spec, not a library. It's designed to be universal and language-agnostic. If you use different languages for your client and server, then by all means stick with GraphQL! It remains the best language-agnostic way to build typesafe data layers.

But the vast majority of projects that use GraphQL are JavaScript-based. The graphql NPM module has 5M downloads/week vs ~300k) for the graphene Python module and ~230k for the graphql Rubygem. It's not a perfect metric, but the signal is clear.

And a growing fraction of JavsScript projects are TypeScript-based. As TypeScript approaches near-universal adoption in the JS ecosystem, that means most GraphQL-based projects will soon be TypeScript-based (if they aren't already).

Here's the thing: most people use GraphQL as a massively over-engineered way to share the type signature of their API with your client. What if you could import that type signature directly into your client using plain ole import syntax? Well...you can.

A demonstrative example

We're going to build the world's simplest typesafe API—no codegen or GraphQL required. There's only one endpoint, with the following type signature:

getUser(id: string) => { id: string, name: string }

For the sake of simplicity, the API is implemented as an Express router. It should be easy to tell what's going on even if you aren't familiar with it.

#1 Define getUser function

// server/routes.ts
export const getUser = async (id: string) => {
  return { id, name: 'Bilbo' };
};

#2 Implement a simple RPC endpoint

We set up a special /rpc endpoint that accepts POST requests with a payload of type { endpoint: string; arguments: any[] }. The endpoint property indicates the function to call (in our case, the only option is "getUser"). We take the arguments array, pass it into the appropriate function, and res.send the results.

// server/router.ts
import express from 'express';
import * as routes from './routes';

type Payload = { endpoint: string; arguments: any[] };

const app = express();
app.post('/rpc', async (req, res) => {
  const payload: Payload = req.body;
  if (payload.endpoint === 'getUser') {
    const user = await routes.getUser(...payload.args);
    return res.status(200).send(user);
  }

  return res.status(404).send('Endpoint not found');
});

A totally skippable aside about RPC

With a REST APIs, you use different URLs (/users, /user/:id) and HTTP request types (e.g GET, POST, etc) to access different functionality. For instance GET /users will fetch all users, GET /user/abc123 will fetch a single user, and POST /user will create a new user.

RPC strips all of that away. Endpoints are just named functions that accept some arguments and return a value. The RPC ethos is to make API calls look and feel like calling a function (albeit one that's defined on a remote server).

GraphQL is an RPC protocol that's been augmented with a schema language and a mechanism for client-side field selection.

#3 Import the type signature of getUser into the client

This is where the magic happens. We directly import the type signature of our API into out client. Yes, it really is this easy.

// client/index.tsx
import type { getUser } from '../server/routes.ts';

Note that this works no matter where routes.ts lives in your file system! It could be in a different Git repo; it could be on your external hard drive. As long as the TypeScript file exists on your computer, you can import it from any other TypeScript file.

"But wait, I can't just import stuff from a different repo!"

This is often true...when you're importing code. For instance, Create React App (CRA) prohibits you from importing anything from outside of your src directory.

But that doesn't apply here. We're using import type syntax, which is a purely static construct. That is, it can only import type signatures, but not data, code, or any runtime construct. For most build systems powered by Babel (including CRA), import type statements (and all type annotations) just get stripped out before the bundling step. If you're using Webpack with Babel, Parcel, or Rollup, this Just Works™. Don't believe me? Play around with this sample repo where you can see this approach working just fine in an unejected CRA project.

You can use import type to import a type from any .ts or .tsx file anywhere on your filesystem. Period. This will work even if your client and server are in different repositories in different parts of your filesystem.

Of course, if multiple developers are working on the same codebase, you'll need to standardize the relative locations of client and server repos, so relative imports work on everyone's machines. You can either establish a convention ("make sure the server and client repos are in the same folder") or use something like Git Submodules.

"But wait, isn't importing stuff from the server into the client a security vulnerability?!"

Yes—when you're importing code. But not here! Remember, we're using the import type syntax. It was introduced in early 2020 (TypeScript 3.8) and it comes with a very important guarantee:

import type never leaves remnants at runtime

All import type declarations are removed completely when you compile your TypeScript code into a JavaScript bundle, no matter what. So yes — you can safely use import type without worrying about accidentally importing sensitive data from your server.

#4 Make an API call

Now that we've imported the type signature of getUser, how do we make strongly typed API call? Answer: a bit of TypeScript magic. (Don't worry, you don't need to understand how this works! Just know that it's possible!)

// client/index.tsx
import type { getUser } from '../server/routes.ts';

type API = {
  getUser: getUser;
  // add additional routes here...
};

const query = <Endpoint extends keyof API>(
  endpoint: Endpoint,
  ...args: Parameters<API[Endpoint]>
): ReturnType<API[Endpoint]> => {
  return fetch('http://localhost:3000/api/rpc', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ endpoint, arguments: args }),
  }).then((response) => response.json()) as any;
};

We now have a fully typesafe query function, which you use like this:

const user = await query('getUser', 'user_abc73bd39ef');
// => Promise<{ id: "user_abc73bd39ef", name: "Bilbo" }>

It's a function called query. The first argument is the name of the endpoint; the second is the input of the function. (As implemented each RPC endpoint can only accept a single input.)

TypeScript validates that the endpoint name is valid...

invalid endpoint name error

...typechecks the arguments...

invalid arguments

...and strongly types the result.

typed payload

And if you update the definition of getUser on your server, the new type signature automatically propagates to your client at the speed of TypeScript, no code generation required. The developer experience is unreal.

By using a next-generation ORM to fetch data in your endpoints, you get most of the value of GraphQL — end-to-end typesafety, easy nested fetching, field selection, etc — in a TypeScript-native way.

If you want, you can use this approach exactly as described; all the code above is available as a Next.js project in this repo. But there's a better way!

Introducing tRPC!

Around 6 months ago, I distilled these ideas into a proof-of-concept library called tRPC. Since then, the inimitable @alexdotjs has turned it into a full-fledged library. Lets see how to implement our getUser example with tRPC. Spoiler alert: it's mindblowingly easy.

If you want to jump straight to the README, check it out at github.com/trpc/trpc — and leave a star if you're feeling generous!

Create a router

// server/index.ts
import * as trpc from '@trpc/server';

const appRouter = trpc.router();
export type AppRouter = typeof appRouter;

Add a query endpoint

Let's implement getUser.

// server/index.ts
import * as trpc from '@trpc/server';

export const appRouter = trpc.router().query('getUser', {
  input: String,
  async resolve(req) {
    req.input; // string
    return { id: req.input, name: 'Bilbo' };
  },
});

A few things to note:

  • You add "endpoints" by calling the .query method. The first argument is a procedure name. The second is an object containing two properties: input and resolve.

  • The input is a function that validates the input. For simplicity, all tRPC procedures accept a single argument. The type signature of this argument is inferred from the return type of the input function.

    I recommend defining your inputs using a schema definition library (I hear Zod is pretty cool 🙃). Far too few developers rigorously validate incoming API payloads; this is a huge security risk and source of bugs. So tRPC has validation baked-in from the start.

    For simplicity, I'm just using the built-in JavaScript String constructor, which accepts any value and converts it into a string.

  • The endpoint logic is defined in the resolve function. A "pseudorequest" is passed into it, which contains input (automatically typed as string in this case) and ctx (more on that later). tRPC infers the return type of your resolver. So make sure the returned value is statically typed! We recommend using a typesafe ORM like Prisma or TypeORM that automatically type the results of database operations; otherwise you'll need to write TypeScript types manually.

What about mutations?

Like GraphQL, tRPC makes a distinction between queries and mutations. Add mutations to your router with the mutation method:

// server/index.ts
import * as trpc from '@trpc/server';
import { z } from 'zod';
export const appRouter = trpc
  .router()
  .query('getUser', {
    input: String,
    async resolve(req) {
      req.input; // string
      return { id: req.input, name: 'Bilbo' };
    },
  })
  .mutation('createUser', {
    input: z.object({ name: z.string() }),
    async resolve(req) {
      return await prisma.user.create({
        data: req.input,
      });
    },
  });

The syntax for the mutation is identical to query. tRPC only makes a distinction because query payloads are cached differently than mutation payloads.

Chainable methods

Important note: when you add multiple endpoints to a router, you must chain the calls to query() and mutation()! This lets tRPC infer the type signature of your whole API. If you're familiar with Express you may be tempted to do this:

export const appRouter = trpc.router();

// ❌ DON'T DO THIS!! ❌

appRouter.query('getUser', {
  input: String,
  async resolve(req) {
    req.input; // string
    return { id: req.input, name: 'Bilbo' };
  },
});

This doesn't work at all. All router methods are immutable—they return an entirely new instance of TRPCRouter, instead of modifying the current instance. So you must chain these methods.

Merging routers

At some point, you'll want to split up your tRPC router into multiple files. If all methods must be chained together, how is this possible?

tRPC provides an easy way to merge routers. I recommend creating a root appRouter and several "child routers" that you merge into it.

import { userRouter } from './routers/user';
import { postRouter } from './routers/post';

export const appRouter = trpc
  .createRouter()
  .merge('users.', userRouter) // prefix userRouter endpoints with "users."
  .merge('posts.', postRouter); // prefix postRouter endpoints with "posts."

All procedure names will be prefixed with the string literal you pass as the first argument of .merge. So a createUser mutation on userRouter will be re-named to users.createUser. This is made possible by the incredible string literal templates feature introduced in TypeScript 4.1!

Handing incoming requests

Now lets use our router to handle some actual HTTP requests. tRPC ships "adapters" that easily let you generate Express middleware or Next.js API routes from your tRPC router.

Next.js integration

// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';

const appRouter = /* ... */;

export default createNextApiHandler({
  router: appRouter,
  createContext: (opts) => {
    return { bearer: opts.req.headers.authorization };
  },
});

Express integration

If you're using Express, do this instead:

import { createExpressMiddleware } from '@trpc/server/adapters/express';

const appRouter = /* ... */;

const app = express();

app.use(
  '/trpc',
  createExpressMiddleware({
    router: AppRouter,
    createContext: () => null, // no context
  })
);

app.listen(80);

Koa, Hapi, Nest.js

Coming soon (and PRs welcome)!

Request context

All server adapters accept createContext function, where you can generate a ctx object from the "raw" HTTP request and response objects (available as opts.req and opts.res). This ctx is available in all your resolvers.

Client-side usage

So what about the client side? The @trpc/client module generates a typesafe "tRPC client"—analagous to the query method we implemented in our toy example. You can import the the type signature of your entire API at once with import type { AppRouter } from '...'. Then pass it into createTRPCClient, along with the URL of your API:

// pages/index.ts
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from './api/[trpc]';

const trpcClient = createTRPCClient<AppRouter>({
  url: 'http://localhost:3000/api',
});

trpcClient.query('getUser', 'user_1238823498');
// => Promise<{ id: string; name: string }>

React integration

If you use React, you can use the @trpc/react package to convert your tRPC client into React hooks powered by React Query. This gets you a ton of cool features like request invalidation, SSR, and cursor-based pagination! 🚀

import { createReactQueryHooks } from '@trpc/react';

// path to your backend server where your AppRouter is defined
import type { AppRouter } from '../server/trpc';

export const trpc = createReactQueryHooks<AppRouter>();

Vue and Svelte

Get in touch if you'd like to build dedicated bindings for those frameworks! Create an issue on the GitHub repo.

Wrapping up

I've been using various iterations of tRPC in production for nearly a year, and it truly feels like living in the future. I'm immensely proud of what tRPC has become. Checkout the docs (and leave a star! 🤩) on the GitHub repo.

There are a lot of other features I could highlight, but that'll do for now. Huge shoutout to @alexdotjs, who picked up a fledgling proof-of-concept four months ago and carried it to the finish line!

Hacker News TL;DR Podcast

Colin is a YC-backed founder and open-source developer. Kenny is a human-computer interaction whiz at Apple. Two best friends report daily from the depths of Hacker News.