trpc icon indicating copy to clipboard operation
trpc copied to clipboard

feat: Define a router independently of its implementation

Open rhinodavid opened this issue 2 years ago โ€ข 19 comments

Describe the feature you'd like to request

See example repo (https://github.com/rhinodavid/trpc-server-definition) for all the details

My request is similar to the one @iduuck wrote about in https://github.com/trpc/trpc/issues/3496#issuecomment-1425705704. tldr: One should not need the server implementation in order to get a typed client. Currently this is not possible (I don't think -- I"m pretty new here) -- all the examples I've seen create the client using the typeof the AppRouter definition.

Describe the solution you'd like to see

My proposed solution is to create utility types to define the router type. Usage would look like this:

// appRouterInterface.ts

import type { defineRouter, defineQueryProcedure } from "@trpc/server";

export type TAppRouter = defineRouter<{
  greet: defineQueryProcedure<{name: string}, {greeting: string}>;
}>

In the server implementation, you'd enforce that the server satisfies the interface:

// server.ts
// ...
import type { TAppRouter } from "./appRouterInterface";
// this could be published as an open source package ^^

export const appRouter = t.router({ /* ... */  }) satisfies TAppRouter;

Then the client is typed with the interface:

// client.ts
import type { TAppRouter } from "./appRouterInterface";

export const client = createTRPCProxyClient<TAppRouter>({ /* ... */ });

The example repo has a complete(?) working PoC.

Describe alternate solutions

Another possible solution is a codegen step as described in https://github.com/trpc/trpc/issues/3496#issuecomment-1425705704.

The pro of this solution is it keeps the existing pattern of [implement router] -> [extract interface].

Some might prefer this, but after a long time using gRPC/stubby I'm more used to the [define interface] -> [implement router] pattern. This pattern is better for large organizations where multiple teams might need to debate and agree on the interface.

The con of the solution is codegen can be finicky. It also doesn't easily solve the workspace organization problem I describe in my example repo readme.

Additional information

No response

๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributing

  • [X] ๐Ÿ™‹โ€โ™‚๏ธ Yes, I'd be down to file a PR implementing this feature!

TRP-52

Funding

  • You can sponsor this specific effort via a Polar.sh pledge below
  • We receive the pledge once the issue is completed & verified
Fund with Polar

rhinodavid avatar Feb 19 '23 01:02 rhinodavid

My understanding is the implementation->interface approach is very much at the core of tRPC's design. It's likely to never be flipped around simply because every time I've seen the suggestion the justification amounts to "because I like it that way", and that's not a great justification for increasing the project's API surface. - my views and understanding here are my own by the way, the core team definitely might have changed their view since this last came up.

One should not need the server implementation in order to get a typed client

Doing this in TypeScript is a bit different to most languages. When you import type {} from you don't pull in any of the implementation code, just the resulting types. Some people choose to put their routers in a library and then the API just imports that library and handles the server stuff.

My proposed solution is to create utility types to define the router type

This is actually likely to be possible today just with a little work on utility types, and could be prototyped in user-land - even released in user-land as a community project if the proposal doesn't get traction.

The best way to then apply your interface post TS4.9 would be like:

const appRouter = router({
  greet: procedure/*etc*/
}) satisfies TAppRouter

The thing is I'm not sure what benefits it really has, it's not functionally necessary as best I can tell, just has some possible organisational benefit to big teams

Nick-Lucas avatar Feb 19 '23 14:02 Nick-Lucas

It's definitely possible -- I did it in my example repo. It's just fragile since it's not upstreamed and uses internal types.

I think the benefit is optionality. User-first API design starts with the API and the implementation follows. It's fine to do it the other way around if the stakeholders are all colocated with the implementation, but once the API needs to be pushed out to external users or some other team or company or whatever then there needs to be a way to define, not infer, the API. trpc is awesome, but right now it doesn't enable this.

rhinodavid avatar Feb 24 '23 02:02 rhinodavid

If you want to push it out to external clients/users you might want to look at the tRPC openapi extension. That way they can consume it like a REST api and use codegen.

It should also be possible to use TSC to generate types in an npm package but I haven't seen a full example of this yet or had time to play with it

Nick-Lucas avatar Feb 24 '23 08:02 Nick-Lucas

Coming from #3699.

Another approach is just to have a "defined router". This router does not have any procedure implementation however still has input & output validation implementations. You can easy put the defined router in shared library with minimal dependencies (only trpc and the validation lib) and use it to implement the server. On the client the same definition can be used to infer the trpcClient. Additional benefit is that the client can re-use the input validation implementation. However this probably needs a major rewrite in the core.

ilijaNL avatar Mar 12 '23 10:03 ilijaNL

FWIW we've been using a slightly different version of https://github.com/rhinodavid/trpc-server-definition at 0x for a few months now and it's working pretty well. In an interface package we define the router spec:

 const teamRouterDefinition =  {
        getById: {
            // Get a team by its ID
            input: z.string().cuid().describe('The team ID'),
            output: team
                .strip()
                .nullable()
                .describe(
                    'The team, or null if not found. '
                ),
            type: 'query',
        }
};

And using that plus a context type (could also include meta) we can produce a router type:

export type TeamRouter = defineTrpcRouter<typeof teamRouterDefinition, { ctx: { isLoggedIn: boolean }}>;

Clients get initialized with the exported router type, and in the router implementation we make sure it satisfies the exported router type.

The only real downside is that we're importing types from internals.

rhinodavid avatar May 10 '23 17:05 rhinodavid

I for one would love to have separated type definitions!

masha256 avatar May 16 '23 18:05 masha256

Here's what I landed on in my codebase. I can pass a typed client to my components without needing the server implementation.

I created a type that recreates the types generated from the createTRPCNext<AppRouter>({...}) function.

import { SignedInAuthObject, SignedOutAuthObject } from '@clerk/clerk-sdk-node';
import { CreateTRPCNextBase } from '@trpc/next';
import { DecoratedProcedureRecord } from '@trpc/react-query/shared';
import { AnyRouter, DefaultErrorShape, RootConfig } from '@trpc/server';
import { CreateRouterInner } from '@trpc/server/dist/core/router';
import { NextPageContext } from 'next';
import superjson from 'superjson';

export type TRPCNextClientLike<RouterDef extends { [key: string]: AnyRouter }> = CreateTRPCNextBase<
  CreateRouterInner<
    RootConfig<{
      ctx: {
        auth: SignedInAuthObject | SignedOutAuthObject;
      };
      meta: object;
      errorShape: DefaultErrorShape;
      transformer: typeof superjson;
    }>,
    RouterDef
  >,
  NextPageContext
> &
  DecoratedProcedureRecord<RouterDef, null>;

I'm using clerk here and that isn't required for an implementation, however the ctx, errorShape, and transformer should all match the t object created when calling the initTRPC.context<Context>().create({...}) function.


Assuming you have a nested router like this:

const exampleRouter = t.router({
  hello: t.procedure.query(() => {
    return 'world';
  }),
});

You can create a component like this:


export function MyComponent({ trpc }: { trpc: TRPCNextClientLike<{ example: typeof exampleRouter }> }) {
  const { data } = trpc.example.hello.useQuery();

  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

This can be done without calling createTRPCNext<AppRouter>({...})


Eventually you will need to assemble an app router and call createTRPCNext<AppRouter>({...}).

When you do, make sure you mount your nested routers on the correct path. Based on the above example it would look like this.

export const appRouter = t.router({
  example: exampleRouter,
});

export type AppRouter = typeof appRouter;

Then you will be able to pass down the trpc object created by createTRPCNext<AppRouter>({...}) to your component.

import { trpc } from '../server/client';

export function MyPage() {
  return (
    <MyComponent trpc={trpc} />
  )
}

I assume the same setup could be used for a react client, but I haven't looked at that.

It would be great if trpc could add some utility types that are easier to create directly in one of the @trpc packages.

ajs11174 avatar May 17 '23 01:05 ajs11174

Separate trpc definitions and implementations should be a core aspect of any RPC protocol given that client / server generation is a fundamental part of this library. Consider for example a monorepo, where multiple services need to generate clients from a trpc router.... you don't want to import the entire service definition as a dependency.

akbog avatar Jul 05 '23 17:07 akbog

Consider for example a monorepo, where multiple services need to generate clients from a trpc router.... you don't want to import the entire service definition as a dependency.

Since you only need the types you can have the API package as a devDependency

juliusmarminge avatar Jul 11 '23 11:07 juliusmarminge

My case: I'm developing a micro-frontend application. The container is a web app that can host multiple widgets loaded in iframes. Widgets can also work in self-hosted mode or even inside another Electron-based container application. In each case, the implementation of the router is different. For instance, when the widget calls showMessageBox function, the web-based container shows a modal dialog, Electron-based container shows native OS message box, and self-hosted widget simply shows window.alert(). I created an abstraction level library for widgets that performs messaging between host application and the widget. Depending on the host, it uses different TRPC links. For iframes it uses MessageChannels (https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), for Electron it uses ipcRender/ipcMain, and for self-hosted mode it just creates an API instance right inside the application. Currently I have to write something like this:

Shared interface:

export type MyApi = {
  showMessageBox(message: string): Promise<void>;
  ...
}

API Proxy:

export function createMyApiRouter(api: MyApi) {
  return t.router({
    showMessageBox: p.input(z.string()).mutation(({input}) => api.showMessageBox(input)),
    ...
  })
}
export type MyApiRouter = ReturnType<typeof createMyApiRouter>;

One of actual implementations:

  const electronRouter = createMyApiRouter({
    showMessageBox: (message) => dialog.showMessageBox(window, { message }),
    ...
  })

API consumer:

export function createProxyApi(opts: CreateTRPCClientOptions<MyApiRouter>): MyApi {
  const client = createTRPCProxyClient<MyApiRouter>(opts);

  return {
    showMessageBox: (message) => client.showMessageBox.mutate(message),
    ...
  };
}

Now, on the widget side I call createProxyApi providing a link (MessageChannel, Electron IPC), on the container side I create a router and connect it to the client link. For self-hosted mode it's trivial, no TRPC is needed at all - I just return the instance of MyApi implementation.

Although this solution works fine, I think there is too much boilerplate code. Router proxies the calls to the actual API, and ProxyApi proxies calls to actual TRPC client. I think it could be simplified somehow.

aesopov avatar Jul 25 '23 20:07 aesopov

I still wish TRPC had better support for splitting up definition and implementation, but for now @misupov 's solution seems to be the best way to achieve this. The need to split up API vs Impl isn't just an aesthetic preferences, but makes the whole system more testable.

For example, in my case, I have a simple react frontend app that's deployed to firebase hosting, and have a nodejs server with an api that comes with additional dependencies on firestore, a postgres database backend, and a large multi-gigabyte custom ml model inference library. For me to build and test the simple frontend app... I now have to depend on the heavy server package unnecessarily just to get the type of the AppRouter.

Being able to split the router def from its implementation means that my react-app can depend on a lightweight api package, and the server can also depend on this lightweight package to implement the interface definition. I can also now mock out the server implementation easily for tests. In addition, I any number of server implementations built that satisfy the TRPC interface and have them run on various different database backends.

For now, I will follow the pattern laid out by @misupov . Thanks!

thekumar avatar Dec 29 '23 22:12 thekumar

+1 I have been messing around migrating to other libraries like ts-rest that do this, but they are really all much worse than tRPC. if tRPC supported this I wouldn't have to migrate.

My use case is developing an SDK for public consumption on top of our APIs

ferdy-roz avatar Apr 23 '24 15:04 ferdy-roz

You can do something like this to define a structure:

interface MyRouter {
  greeting: QueryProcedure<{
    input: {
      name: string;
    };
    output: {
      message: string;
    };
  }>;
}

then define the router accordingly

export const myRouter = {
  greeting: publicProcedure
    .input(
      z.object({
        name: z.string(),
      }),
    )
    .query((opts) => {
      return {
        message: `Hello ${opts.input.name}`,
      };
    }),
} satisfies MyRouter;

the above can be used in e.g. t.router({ myRouter })

It's totally possible to do a public SDK with tRPC, but it'd take me a some hours to build a good reference - feel free to email me at [email protected] if you want to hire and pay me to do it.

KATT avatar Apr 23 '24 15:04 KATT

@KATT that's helpful, how do you use MyRouter here when calling e.g. createTRPCProxyClient?

I have something like

class SdkClient {
   ...
   // TS2344: Type MyRouter does not satisfy the constraint Router<any, any>
   // Type MyRouter is missing the following properties from type Router<any, any>: _def, createCaller
   private trpc: ReturnType<typeof createTRPCProxyClient<MyRouter>>;
}

ferdy-roz avatar Apr 24 '24 17:04 ferdy-roz

I am also looking forward to see trpc support structure only definition.

The situation I encountered is:

We don't use monorepo, but have many microservices. In order to avoid duplicated type definitions, we want to publish a private npm package to store API types, the implemention should in microservices, and usage is in frontend.

If frontend directly import from the backend (as document said), then we need to publish all microservices we have with npm, which is not good.

My current workaround is like (Inspired by @misupov , thanks!)

// Defintion 
type TQuery<PB extends ProcedureBuilder<any>> = Parameters<PB["query"]>[0];

const searchApi = publicProcedure
  .input(z.object({ keyword: z.string() }))
  .output(z.array(z.object({ query: z.string(), title: z.string() })));

const completionApi = publicProcedure
  .input(z.object({ keyword: z.string() }))
  .output(z.array(z.object({ option: z.string() })));

export const createSearchRouter = (queries: {
  searchQuery: TQuery<typeof searchApi>;
  completionQuery: TQuery<typeof completionApi>;
}) => {
  return router({
    search: searchApi.query(queries.searchQuery),
    completions: completionApi.query(queries.completionQuery),
  });
};

// Implementation
export const appRouter = createSearchRouter({ searchQuery: xxx, completionQuery :xxx })

// Usage 
const client = createTRPCClient<ReturnType<typeof createSearchRouter>>()

InfiniteXyy avatar Apr 30 '24 03:04 InfiniteXyy