Limitation of TRPC's Type Inference and How We Improved It
Tags: webdev, javascript, typescript, trpc
Published: 2023-07-11 12:14:23

[TRPC](https://trpc.io), despite its short history, has gained much popularity in the Node.js/TypeScript community. One of the main reasons for its fast adoption comes from its brilliantly light-weighted design - there's no schema to write, no generator to run. Everything works magically, leveraging TypeScript's powerful type inference capability. It's one of the API toolkits providing the best developer experiences.

However, its power is also limited by the upper bound of type inference's capability. Let's look at an example. Suppose I have a backend service function that fetches a blog post whose signature looks like this:

```ts
export type Post = { id: number; title: string; }

export type User = { id: number; name: string; }

export type LoadPostArgs = {
    id: number;
    withAuthor?: boolean;
};

export type LoadPostResult<T extends LoadPostArgs> =
    T['withAuthor'] extends true ? Post & { author: User } : Post;

export function loadPost<T extends LoadPostArgs>(args: T): LoadPostResult<T> {
	...
}
```

What's unique about this generic function is that its return type "adapts" to the input type:

```ts
// p1 is typed `Post`
const p1 = loadPost({id: 1});

// p2 is typed `Post & { author: User }`
const p2 = loadPost({id: 1, withAuthor: true});
```

This “dynamic” typing makes a pleasant auto-completion experience and helps catch errors at compile time.

Let’s expose this function via a tRPC router:

```ts
// routers.ts
const appRouter = router({
    loadPost: publicProcedure
        .input(z.object({ id: z.number(), withAuthor: z.boolean().optional() }))
        .query(({ input }) => loadPost(input)),
});

export type AppRouter = typeof appRouter;
```

Then consume it from the client side:

```ts
const trpc = createTRPCProxyClient<AppRouter>({...});

const p1 = await trpc.loadPost.query({ id: 1 });
const p2 = await trpc.loadPost.query({ id: 1, withAuthor: true });
```

Both `p1` and `p2` are typed as `Post`. The dynamicity is lost.

## Why does that happen?

Let's take a look at the generic function again:

```ts
export function loadPost<T extends LoadPostArgs>(args: T): LoadPostResult<T> {
	...
}
```

When it's called, the generic type parameter `T` is inferred from the type of the concrete input argument (as long as it satisfies the LoadPostArgs type). After that, the TypeScript compiler can further infer the return type based on the inferred `T`. The key is that everything happens inside the context of a function call.

Although tRPC gives the illusion of simple function calling when invoking a remote API, its situation is very different. During server-side router registration, the input's shape is statically analyzed from the zod schema, and there's no way of defining a "generic" router that you can instantiate with concrete types on the client side.

To make such "dynamic" generic typing work, tRPC needs to be able to hold "uninstantiated" generic function types internally and instantiate them in a different context. This requires a language feature called "Higher-Kinded Types" which TypeScript hasn't implemented yet. In fact, the feature request was created back in 2014, and we can celebrate its 10-yr anniversary soon 😀.

{% embed https://github.com/microsoft/TypeScript/issues/1213 %}

Just like "Higher Order Functions" are functions that return other functions, "Higher-Kinded Types" are types that create other types. It's probably one of the most obscure areas of typing and language design, but if you're interested, here're a few pointers to follow:

{% embed https://code.lol/post/programming/higher-kinded-types/ %}
{% embed https://dev.to/effect-ts/encoding-of-hkts-in-typescript-5c3 %}

## How does this limitation hurt us?

A careful reader might have found some clue: the loadPost function's typing pattern is extensively used by [Prisma ORM](https://prisma.io). It's where Prisma's best features come from: it doesn't just type things; it types them perfectly.

```ts
// post is typed `Post & { author: User }`
const post = await prisma.post.findFirst({
	where: { id: postId }, 
	include: { author: true }
);
```

We're building a toolkit called [ZenStack](https://github.com/zenstackhq/zenstack), which extends Prisma's schema and runtime to add access control capability to the awesome ORM. It also provides plugins to generate different styles of APIs from its schema (powered by the access-control-enabled Prisma), and tRPC is one of them. The generated routers allow you to call Prisma's CRUD methods via tRPC with identical signatures:

```ts
const post = await trpc.post.findFirst.query({
	where: { id: postId }, 
	include: { author: true }
);

```

However, the generic typing limitation prevents our users from enjoying Prisma's best at the tRPC API level.

## The brute-force fix

When type inference hits its limit, we can always fall back to code generation. The key insight is that, although the tRPC router's typing is lossy, the behavior is correct at runtime. I.e., calling loadPost with `{ withAuthor: true }` does return an `author` field in the response. Only the typing is imprecise. And we can fix the typing with some code generation that simply "corrects" the typing from the client side.

To achieve that, we generate a `createMyTRPCProxyClient` helper to create a tRPC client with type fixing. The idea looks like the following:

```ts
// the type of `loadPost` function
export type LoadPostFn<T extends LoadPostArgs = LoadPostArgs> = (
    args: T
) => LoadPostResult<T>;

function createMyTRPCProxyClient(opts: CreateTRPCClientOptions<AppRouter>) {
    // create a regular trpc client
    const _trpc = createTRPCProxyClient<AppRouter>(opts);
    // cast it to fix typing of the `query` function of the `loadPost` API
    return _trpc as Omit<typeof _trpc, 'loadPost'> & {
        loadPost: {
            query: <T extends Parameters<LoadPostFn>[0]>(
                input: T
            ) => Promise<ReturnType<LoadPostFn<T>>>;
        };
    };
}
```

Now the client-side typing is all good:

```ts
const trpc = createMyTRPCProxyClient({ ... });

// post is typed as `Post & { author: User }`
const post = await trpc.post.findFirst.query({
	where: { id: postId }, 
	include: { author: true }
);
```

## Type-inference vs. code generation

Type inference is light and fast. Your changes are reflected instantly inside IDE without running code generation steps. When possible, it should be a preferred approach. But when you hit the limit of it, don't shy away from falling back to code generation. For ZenStack, this fallback is especially natural because the tRPC routers already came from code generation. It doesn't hurt to generate a bit more 😄.

---

Hope you enjoyed the reading and find the approach interesting. We built the ZenStack toolkit believing that a powerful schema can bring many benefits that simplify the construction of a full-stack application. If you like the idea, check out our GitHub page for more details!

[https://github.com/zenstackhq/zenstack](https://github.com/zenstackhq/zenstack)

[![Star me on github](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4vfeu3hwubwbxm1vgagy.gif)](https://github.com/zenstackhq/zenstack)