Conquering tRPC Type Errors in Monorepos

FloffahFloffah
4 min read

After a quick google, it seems it is a common experience for developers using tRPC to be discouraged from using it in monorepos due to the errors that arise from its complex type-based design. But I'm here to tell you how to fix these errors without doing extensive research on your own. It's not a glamorous fix, but it works.

What's the issue?

One of tRPC's main philosophies is that it allows you to create a fully-typed API that can be consumed on your client (or multiple clients), but the main use-case for it is in a single-package project where it's embedded in a NextJS app for example.

As soon as you convert your project into a monorepo where the API is it's own package, it sounds easy to set up, but there's an immediate blockage.

tRPC not only has runtime validation for calling procedures, but it has validation inside it's complex type system preventing you from going wrong. In theory this is great and helps the majority of the time, but for some reason it freaks out when you try and use tRPC within monorepos.

I'm unsure what specifically in their code causes this, but it has something to do with Typescript's attempt to find and understand the type declarations within the package you're referencing.

The fix

Building your API

There are many steps we need follow in order to get this working. The first is the built tool you're using. When I first encountered this issue I was using tsup (an esbuild wrapper for bundling types). I discovered that even with the fixes that follow, using a build-tool that mangles your types in any way causes Typescript to freak out. Good news! You don't need to get rid of your build tool entirely, just disable declarations and add the tsc --emitDeclarationOnly -b command to your toolchain. As an example, this is my setup:

{
  "main": "./dist/index.js",
  "types": "./types/index.d.ts",
  "scripts": {
    "dev": "concurrently \"tsup --watch\" \"tsc -w\" \"tsc-alias -w\"",
    "build": "tsup && tsc -b --emitDeclarationOnly && tsc-alias"
  }
}

With this, you can bundle your code and compile your type declarations alongside it. Install the tsc-alias package if you are using path aliases (e.g. @/) in your project.

Unfortunately, although your API's code is still bundled nicely, you will have more than one declaration file... but it's a necessary sacrifice and you won't be editing your dist folder anyway.

Doing it this way is a long way for a shortcut and only really benefits from large tRPC APIs being deployed to serverless environments where the bundle needs to be downloaded and parsed often. It is far easier and faster to just use the typescript compiler and eliminate the original build tool all together.

TSConfig setup

The above won't fix it alone, we need to add references to our config files.

First, add "composite": true to the compiler options within the tsconfig file in your tRPC API package. This allows your API package to be used with project references. It shouldn't break anything (at least not in my experience), but it's worth noting that the Typescript docs say it "enables constraints".

Next, we add the reference its self. In all of the packages that consume this package you need to add the following to their associated TSConfig files.


  "references": [
    {
      "path": "../../apps/api/tsconfig.json"
    }
  ]

Note that this doesn't go inside your compiler options, it should be a sibling of include, etc.

Usage within NextJS

Due to how Typescript's compiler API works, Next only reads the compilerOptions and include fields of our TSConfig and ignores all of the other fields - including references. You can fix this any way you want, but here's the way I did it.

Firstly, I disabled type-checking in my Next config


/** @type {import('next').NextConfig} */
const nextConfig = {
    typescript: {
        // part of lint step, next ignores tsconfig references and breaks trpc
        ignoreBuildErrors: true,
    },
};

export default nextConfig;

Wait! This isn't bad. In my monorepos, I use Turborepo to wrangle dev and prod builds. Here is the build part of my Turborepo config:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "outputs": [
        ".next/**",
        "!.next/cache/**",
        "dist/**"
      ],
      "dependsOn": [
        "^build",
        "lint"
      ]
    },
    "lint": {
      "dependsOn": [
        "^build"
      ]
    }
  }
}

As you can see, I add a lint step that is depended on by the build step. Then, in the package.json file we re-add type-checking as our lint step:

{
  "scripts": {
    "lint": "tsc --noEmit && next lint"
  }
}

Now, every time we trigger a build, our Next app will be type-checked properly, and it won't freak out when it sees tRPC!

Conclusion

tRPC is an incredible method for crafting type-safe and end-to-end APIs, but there's always a catch. This time, we are able to overcome it. I sure learned something when figuring out how to fix this, and I hope you have too! Hopefully this fixes it for you, and if I got anything wrong don't hesitate to send me a message.

You can see a working version of this in this repository.

1
Subscribe to my newsletter

Read articles from Floffah directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Floffah
Floffah

I am a full stack web developer from Scotland. I have some pretty cool projects going on over at my GitHub page. Check it out!