DEV Community

Cover image for Limitation of TRPC's Type Inference and How We Improved It
ymc9 for ZenStack

Posted on

Limitation of TRPC's Type Inference and How We Improved It

TRPC, 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:

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> {
    ...
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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:

// 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;
Enter fullscreen mode Exit fullscreen mode

Then consume it from the client side:

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

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

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:

export function loadPost<T extends LoadPostArgs>(args: T): LoadPostResult<T> {
    ...
}
Enter fullscreen mode Exit fullscreen mode

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 😀.

Allow classes to be parametric in other parametric classes #1213

This is a proposal for allowing generics as type parameters. It's currently possible to write specific examples of monads, but in order to write the interface that all monads satisfy, I propose writing

interface Monad<T<~>> {
  map<A, B>(f: (a: A) => B): T<A> => T<B>;
  lift<A>(a: A): T<A>;
  join<A>(tta: T<T<A>>): T<A>;
}
Enter fullscreen mode Exit fullscreen mode

Similarly, it's possible to write specific examples of cartesian functors, but in order to write the interface that all cartesian functors satisfy, I propose writing

interface Cartesian<T<~>> {
  all<A>(a: Array<T<A>>): T<Array<A>>;
}
Enter fullscreen mode Exit fullscreen mode

Parametric type parameters can take any number of arguments:

interface Foo<T<~,~>> {
  bar<A, B>(f: (a: A) => B): T<A, B>;
}
Enter fullscreen mode Exit fullscreen mode

That is, when a type parameter is followed by a tilde and a natural arity, the type parameter should be allowed to be used as a generic type with the given arity in the rest of the declaration.

Just as is the case now, when implementing such an interface, the generic type parameters should be filled in:

class ArrayMonad<A> implements Monad<Array> {
  map<A, B>(f: (a:A) => B): Array<A> => Array<B> {
    return (arr: Array<A>) =>  arr.map(f);
  }
  lift<A>(a: A): Array<A> { return [a]; }
  join<A>(tta: Array<Array<A>>): Array<A> {
    return tta.reduce((prev, cur) => prev.concat(cur));
  }
}
Enter fullscreen mode Exit fullscreen mode

In addition to directly allowing compositions of generic types in the arguments, I propose that typedefs also support defining generics in this way (see issue 308):

typedef Maybe<Array<~>> Composite<~> ;
class Foo implements Monad<Composite<~>> { ... }
Enter fullscreen mode Exit fullscreen mode

The arities of the definition and the alias must match for the typedef to be valid.

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:

Higher Kinded Types in Typescript - Adventures in Typescript

HKTs are a powerful abstraction. Just as there are different types of higher-order functions, so are there so-called ‘higher-kinded types’. Taxonomy This blog post concerns one particular type of HKT - to define the taxonomy, first we will cover a few types, and a way they can be categorized. We can classify types in terms of ‘order’, a rough level of abstraction. Here are a few zero-order types that exist:

favicon code.lol

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. It's where Prisma's best features come from: it doesn't just type things; it types them perfectly.

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

We're building a toolkit called 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:

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

Enter fullscreen mode Exit fullscreen mode

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:

// 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>>>;
        };
    };
}
Enter fullscreen mode Exit fullscreen mode

Now the client-side typing is all good:

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

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

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

Star me on github

Top comments (0)