NestJS tRPC

repository·main·Indexed 43 days ago

https://github.com/kevinedry/nestjs-trpc

An opinionated adapter that integrates tRPC into the NestJS framework, providing end-to-end typesafety using NestJS-style decorators and dependency injection. Features include a Rust-based CLI for AppRouter type generation, Zod validation, and support for tRPC v11.

Tokens
35.2K
Snippets
113
Records
155
Agent score
56%

What's inside NestJS tRPC

  1. Understand the recommended file structure for NestJS-tRPC

    main

    NestJS-tRPC follows the opinionated patterns of NestJS. To maintain consistency with the NestJS ecosystem, it is recommended to organize your files using a structure similar to standard NestJS modules.

    Core files typically include:

    • app.router.ts: Acts like a NestJS controller; defines your tRPC routes.
    • app.middleware.ts: Acts like NestJS guards or middlewares; used to modify the request context (ctx).
    • app.context.ts: A class used to modify the application context.
    • app.service.ts: A standard NestJS service containing business logic.
    • app.module.ts: The root module of your application.
    • main.ts: The application entry point using NestFactory to bootstrap the instance.
  2. Use Procedure Metadata in Middlewares

    main

    You can attach arbitrary metadata to procedures using the meta property in @Query() or @Mutation() decorators. Middlewares can then read this metadata to implement logic like role-based access control (RBAC).

    To achieve full type safety, pass the metadata interface as a generic to the TRPCMiddleware implementation and use it within MiddlewareOptions.

    // 1. Define metadata in the procedure
    @Query({
      input: z.object({ userId: z.string() }),
      meta: { roles: ['admin'] },
    })
    async getUser(@Input('userId') userId: string) {}
    
    // 2. Read metadata in the middleware
    interface RolesMeta { roles: string[]; }
    
    @Injectable()
    export class RolesMiddleware implements TRPCMiddleware<RolesMeta> {
      async use(opts: MiddlewareOptions<AuthMiddlewareContext, Record<string, unknown>, RolesMeta>): Promise<MiddlewareResponse> {
        const { meta, ctx, next } = opts;
    
        if (!meta.roles.includes(ctx.auth.userId)) {
          throw new TRPCError({ message: 'Insufficient permissions.', code: 'FORBIDDEN' });
        }
    
        return next();
      }
    }
  3. Modify and Type the tRPC Context in Middleware

    main

    Middlewares can augment the context passed to downstream procedures by calling next({ ctx: { ... } }).

    To ensure type safety, use the TReturnContext generic in MiddlewareOptions. When a middleware modifies the context, nestjs-trpc generates a new type named [MiddlewareName]MiddlewareContext (e.g., AuthMiddlewareContext). You can then import this generated type and use it with the @Context() decorator in your procedures.

    // 1. Define the return context shape
    interface AuthReturnContext {
      auth: { userId: string };
    }
    
    // 2. Implement middleware with the generic
    @Injectable()
    export class AuthMiddleware implements TRPCMiddleware {
      async use(opts: MiddlewareOptions<Context, AuthReturnContext>): Promise<MiddlewareResponse> {
          const { ctx, next } = opts;
          // ... logic to get session ...
          return next({
            ctx: { auth: { userId: session.user.id } }
          });
      }
    }
    
    // 3. Use the generated type in a procedure
    @UseMiddlewares(AuthMiddleware)
    @Query({ output: userSchema })
    async getUserProfile(@Context() ctx: AuthMiddlewareContext): Promise<User> {
      const { userId } = ctx.auth; // Fully typed
      return await this.userService.getUserById(userId);
    }
  4. Compare GraphQL vs NestJS-tRPC

    main

    When deciding whether to migrate from GraphQL to NestJS-tRPC, consider the following advantages and trade-offs:

    Advantages of NestJS-tRPC

    • End-to-End Type Safety: Achieves seamless type inference from backend to frontend without requiring code generation, reducing runtime errors.
    • Simplified Development: Eliminates the need for manual schema definitions and resolvers. You can use existing models and business logic directly, reducing boilerplate.
    • Familiar NestJS Patterns: Integrates smoothly with the NestJS ecosystem, allowing you to use existing NestJS knowledge.
    • Performance: Offers a lighter-weight solution with reduced overhead in request processing and response serialization compared to full GraphQL implementations.
    • Flexible Querying: Supports query composition, including batching multiple procedure calls in a single HTTP request and efficient caching/invalidation strategies.
    • Developer Experience: Provides full IDE support (autocomplete, IntelliSense), easier debugging, and simplified testing.
    • Gradual Migration: Can be integrated alongside existing NestJS modules, allowing for a phased transition.

    Migration Considerations

    • Learning Curve: Teams must learn tRPC concepts (though these are generally straightforward for TypeScript developers).
    • Client-Side Updates: You must update existing client-side code to use the tRPC client instead of GraphQL queries.
    • Feature Parity: Evaluate if tRPC meets all your current use cases.
    WARNING

    If your application relies heavily on GraphQL-specific features like subscriptions, carefully assess if tRPC can meet these needs before migrating.

  5. Understand tRPC core concepts: Routers, Procedures, and Type Inference

    main

    tRPC is designed for end-to-end type safety without code generation. It relies on several key abstractions:

    • Router: A collection of related procedures that organizes your API.
    • Procedure: A single function exposed via a router. Procedures are categorized into two types:
      • Query: A read-only operation.
      • Mutation: A write operation (used for creating, updating, or deleting data).
    • Type Inference: tRPC uses TypeScript's type inference to automatically sync types between the server and the client. By exporting the AppRouter type, the client knows exactly what inputs and outputs to expect.
    • Middlewares: Logic that can be injected into procedures for cross-cutting concerns like authentication, authorization, or logging.
    • Data Transformation: Support for custom transformers to handle serialization and deserialization of complex data structures.
  6. How Subscriptions support Middleware and DI

    main

    Subscriptions in nestjs-trpc behave like queries and mutations:

    • Middlewares: Use the @UseMiddlewares() decorator to execute logic (like authentication) before the generator starts.
    • Dependency Injection: Subscription routers are standard NestJS classes; you can inject services via the constructor to access business logic.
    @UseMiddlewares(AuthMiddleware)
    @Subscription({
      input: z.object({ channelId: z.string() }),
    })
    async *onMessage(@Input('channelId') channelId: string) {
      // Only authenticated users reach this point
    }
  7. Use the generated Context type

    main

    When you implement a TRPCContext class, nestjs-trpc automatically generates a Context type based on the return value of your create() method.

    You can import this Context type from nestjs-trpc/types to ensure type safety within your middlewares and procedures.

    // If your context is defined as:
    // create(opts: ContextOptions): Record<string, unknown> {
    //   return { isContextApplied: true };
    // }
    
    // You can import the generated type from:
    // nestjs-trpc/types
    // export type Context = {
    //   isContextApplied: boolean;
    // }
  8. How AppRouter generation works

    main

    The AppRouter acts as a bridge between NestJS's runtime-centric architecture and tRPC's compile-time centric type inference.

    1. Definition: You define your API using @Router() classes and decorators like @Query(), @Mutation(), and @Input(). You can use the alias option in @Router({ alias: '...' }) to name the router segment in the tRPC tree.
    2. Generation: When autoSchemaFile is configured, the library generates a file (e.g., src/@generated/server.ts) that contains a t.router({...}) definition.
    3. Type Inference: This generated file exports export type AppRouter = typeof appRouter;. Your frontend client imports this type to provide autocomplete and type checking.
    4. Runtime: The generated code contains no actual logic; it uses placeholders to satisfy TypeScript. Your real logic is executed by the NestJS application at runtime.
    // 1. Define Router in NestJS
    @Router({ alias: 'users' })
    export class UserRouter {
        @Query({ input: z.object({ userId: z.string() }) })
        async getUserById(@Input('userId') userId: string) {
            // Real logic here
        }
    }
    
    // 2. Generated Output (src/@generated/server.ts)
    const appRouter = t.router({
        users: {
            getUserById: publicProcedure
                .input(z.object({ userId: z.string() }))
                .query(async () => 'PLACEHOLDER_DO_NOT_REMOVE' as any),
        },
    });
    export type AppRouter = typeof appRouter;
  9. Migrating from tRPC v10 to v11

    main

    When upgrading nestjs-trpc from tRPC v10 to v11, most core features like decorators (@Router, @Query, @Mutation, @UseMiddlewares, @Context) and Zod validation remain unchanged. The primary breaking changes involve how data transformers are configured and how clients are instantiated.

    Key Changes Summary:

    • Data Transformers: Must be moved from the root client configuration into the links configuration.
    • Client Creation: createTRPCProxyClient is renamed to createTRPCClient (the old name is deprecated).
    • SSG Helpers: createProxySSGHelpers is renamed to createSSGHelpers.
    • Subscriptions: v11 introduces new SSE-based subscriptions, though WebSockets remain supported.
  10. Apply Middlewares to Routers and Procedures

    main

    You can apply middlewares at three levels of granularity. The execution order is: Global Middlewares $\rightarrow$ Router Middlewares $\rightarrow$ Procedure Middlewares $\rightarrow$ Handler.

    1. Global Middlewares: Apply to every procedure in the application by adding them to the globalMiddlewares array in TRPCModule.forRoot().
    2. Router Middlewares: Apply to all procedures within a specific router class using the @UseMiddlewares() decorator on the class.
    3. Procedure Middlewares: Apply to a specific method (query or mutation) using the @UseMiddlewares() decorator on the method.
    // Global Middleware Registration
    @Module({
      imports: [
        TRPCModule.forRoot({
          globalMiddlewares: [LoggedMiddleware, ErrorReportingMiddleware],
        }),
      ],
      providers: [LoggedMiddleware, ErrorReportingMiddleware],
    })
    export class AppModule {}
    
    // Router and Procedure Middleware Application
    @Router()
    export class DogsRouter {
      @UseMiddlewares(LoggedMiddleware, AuthMiddleware)
      @Query({ output: z.array(dogSchema) })
      async findAll(): Promise<Dog[]> {
        // ...
      }
    }