Easily hide/modify output using tRPC middleware

When using tRPC, it’s easy to run into a problem of duplicating output logic to try to hide/modify/change the output consistently. I’m going to show you how you can use tRPC middleware to dedupe that logic in a clean and reusable way.

Let’s imagine your application has Projects and Users.

We have two types of Users (admins and members).

For simplicity, let’s say we only want to return project descriptions for admins. Members cannot view descriptions.

Simplest solution

projects.ts
export const projectsRouter = createTrpcRouter({
  getProjects: publicProcedure.query(async ({ ctx }) => {
    const isAdmin = ctx.user.type === "admin";
    const projects = await ctx.prisma.projects.findMany();

    return projects.map((project) => {
      return {
        ...project,
        description: isAdmin ? project.description : "",
      };
    });
  }),
  getProjectById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const isAdmin = ctx.user.type === "admin";
      const project = await ctx.prisma.projects.findFirstOrThrow({ where: { id: input.id } });

      return {
        ...project,
        description: isAdmin ? project.description : "",
      };
    }),
});

Did you notice the duplication? 😱

Extracting shared logic

Let’s try to refactor some of this logic so there’s less duplicated code.

We’ll create a mapProject function that takes in a project and whether the user is an admin and decides how to map the project.

projects.ts
import type { Project } from '@prisma/client';

const mapProject = (project: Project, isAdmin: boolean) => ({
  ...project,
  description: isAdmin ? project.description : '',
});

export const projectsRouter = createTrpcRouter({
  getProjects: publicProcedure.query(async ({ ctx }) => {
    const isAdmin = ctx.user.type === "admin";
    const projects = await ctx.prisma.projects.findMany();
    return projects.map((project) => mapProject(project, isAdmin));
  }),
  getProjectById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const isAdmin = ctx.user.type === "admin";
      const project = await ctx.prisma.projects.findFirstOrThrow({ where: { id: input.id } });
      return mapProject(project, isAdmin);
    }),
});

Using middleware to define the function with “context”

Alright, this is starting to look a little cleaner. Let’s introduce “middleware” into the mix to see how this can help us clean up some more code.

Instead of creating the mapProject function outside of tRPC and using it, we can actually create it within our tRPC middleware. It will have all the information from the request that we need to make a decision. In this case, we can check if the user is an admin and decide if we should return a “description” from within the middleware.

projects.ts
import type { Project } from '@prisma/client';

const projectProcedure = publicProcedure.use(({ ctx, next }) => {
  return next({
    ctx: {
      mapProject: (project: Project) => {
        const isAdmin = ctx.user.type === "admin";

        return {
          ...project,
          description: isAdmin ? project.description : '',
        };
      },
    }
  })
});

export const projectsRouter = createTrpcRouter({
  getProjects: projectProcedure.query(async ({ ctx }) => {
    const projects = await ctx.prisma.projects.findMany();
    return projects.map(ctx.mapProject);
  }),
  getProjectById: projectProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.mapProject(
        await ctx.prisma.projects.findFirstOrThrow({ where: { id: input.id } })
      );
    }),
});

Using tRPC middleware to it’s full potential

We can actually do the entire mapping within the middleware so we don’t need to even think about it inside of queries/mutations. Imagine a new engineer joins the project, you don’t want to have to remind them to use the ctx.mapProject function.

Maybe you’re on vacation and they accidentally ship code that reveals the description to all members. That would be horrible.

Let’s see how tRPC middleware can solve this for us in a clean, reusable way.

First…our updated router. Look how simple this is.

so refreshing

projects.ts
export const projectsRouter = createTrpcRouter({
  getProjects: publicProcedure.query(async ({ ctx }) => ctx.prisma.projects.findMany(),
  getProjectById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ ctx, input }) => ctx.prisma.projects.findFirstOrThrow({ where: { id: input.id } })),
});

Already, here’s the moment we’ve all been waiting for…

drum roll please

projects.ts
const isProject = (project: unknown): project is Project => false; // needs to be implemented
const isProjects = (projects: unknown): projects is Project[] => Array.isArray(projects) && projects.every(isProject);

const projectProcedure = publicProcedure.use(({ ctx, next }) => {
  const result = await next();

  if (!result.ok) {
    return result;
  }

  const mapProject = (project: Project) =>  {
    const isAdmin = ctx.user.type === "admin";

    return {
      ...project,
      description: isAdmin ? project.description : '',
    }
  };

  if (isProjects(result.data) || isProject(result.data)) {
    return {
      ...result,
      data: isProjects(result.data)
        ? result.data.map((project) => mapProject(project))
        : mapProject(result.data),
    };
  }

  return result;
});

In our tRPC middleware, we can actually call the next() function and await the result. This will return the result from the query/mutation which we can use before returning the value to the client.

We await the result, make sure it was successful and then we check the return type to see if it is a project or list of projects, if so, we call the mapProject on each project to hide the description for non-admin users.

Full Example

projects.ts
import type { Project } from '@prisma/client';
import { createTrpcRouter, publicProcedure } from '../trpc';

const isProject = (project: unknown): project is Project => false; // needs to be implemented
const isProjects = (projects: unknown): projects is Project[] => Array.isArray(projects) && projects.every(isProject);

const projectProcedure = publicProcedure.use(({ ctx, next }) => {
  const result = await next();

  if (!result.ok) {
    return result;
  }

  const mapProject = (project: Project) =>  {
    const isAdmin = ctx.user.type === "admin";

    return {
      ...project,
      description: isAdmin ? project.description : '',
    }
  };

  if (isProjects(result.data) || isProject(result.data)) {
    return {
      ...result,
      data: isProjects(result.data)
        ? result.data.map((project) => mapProject(project))
        : mapProject(result.data),
    };
  }

  return result;
});

export const projectsRouter = createTrpcRouter({
  getProjects: publicProcedure.query(async ({ ctx }) => ctx.prisma.projects.findMany(),
  getProjectById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ ctx, input }) => ctx.prisma.projects.findFirstOrThrow({ where: { id: input.id } })),
});