Menu
Zurück zum Blog
1 min read
Web Development

tRPC: End-to-End Type Safety ohne Code-Generierung

tRPC für vollständig typsichere APIs zwischen Frontend und Backend. Keine Schemas, keine Code-Generierung – nur TypeScript.

tRPCType SafetyAPITypeScriptFull-StackEnd-to-End Types
tRPC: End-to-End Type Safety ohne Code-Generierung

tRPC: End-to-End Type Safety ohne Code-Generierung

Meta-Description: tRPC für vollständig typsichere APIs zwischen Frontend und Backend. Keine Schemas, keine Code-Generierung – nur TypeScript.

Keywords: tRPC, Type Safety, API, TypeScript, Full-Stack, End-to-End Types, React Query, Next.js


Einführung

tRPC eliminiert die Grenze zwischen Frontend und Backend. Ein TypeScript-Typ, der vom Server kommt, ist automatisch im Client verfügbar – ohne REST, ohne GraphQL, ohne Code-Generierung.


Das Problem

// REST: Kein automatischer Type-Share
// Backend
app.get('/api/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
});

// Frontend - Types müssen manuell synchronisiert werden
const response = await fetch('/api/users/123');
const user: User = await response.json(); // Hoffen dass es stimmt...

Die Lösung: tRPC

// Router Definition (Backend)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

const appRouter = t.router({
  user: t.router({
    getById: t.procedure
      .input(z.string())
      .query(async ({ input }) => {
        return await prisma.user.findUnique({
          where: { id: input }
        });
      })
  })
});

export type AppRouter = typeof appRouter;

// Client (Frontend)
import { trpc } from './utils/trpc';

function UserProfile({ userId }: { userId: string }) {
  // Vollständig typisiert! 🎉
  const { data: user } = trpc.user.getById.useQuery(userId);

  // user ist automatisch typisiert als:
  // { id: string; name: string; email: string; ... } | null | undefined
}

Setup mit Next.js App Router

1. Server-Setup

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null
      }
    };
  }
});

export const router = t.router;
export const publicProcedure = t.procedure;

// Middleware für Auth
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      user: ctx.session.user
    }
  });
});

2. Router Definition

// src/server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

export const appRouter = router({
  user: userRouter,
  post: postRouter
});

export type AppRouter = typeof appRouter;
// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const userRouter = router({
  // Public Query
  getById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      return prisma.user.findUnique({
        where: { id: input },
        select: {
          id: true,
          name: true,
          email: true,
          image: true
        }
      });
    }),

  // Protected Query
  getMe: protectedProcedure
    .query(async ({ ctx }) => {
      return prisma.user.findUnique({
        where: { id: ctx.user.id }
      });
    }),

  // Mutation mit Validation
  update: protectedProcedure
    .input(z.object({
      name: z.string().min(2).optional(),
      bio: z.string().max(500).optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return prisma.user.update({
        where: { id: ctx.user.id },
        data: input
      });
    })
});

3. API Route Handler

// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext
  });

export { handler as GET, handler as POST };

4. Client Setup

// src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import superjson from 'superjson';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson
        })
      ]
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Client-Side Usage

Queries

'use client';

import { trpc } from '@/utils/trpc';

function UserProfile({ userId }: { userId: string }) {
  // Basic Query
  const { data, isLoading, error } = trpc.user.getById.useQuery(userId);

  // Mit Options
  const { data: user } = trpc.user.getById.useQuery(userId, {
    enabled: !!userId,
    staleTime: 60 * 1000,
    refetchOnWindowFocus: false
  });

  // Suspense Query
  const [user] = trpc.user.getById.useSuspenseQuery(userId);

  if (isLoading) return <Skeleton />;
  if (error) return <Error message={error.message} />;

  return <div>{data?.name}</div>;
}

Mutations

function UpdateProfileForm() {
  const utils = trpc.useUtils();

  const updateUser = trpc.user.update.useMutation({
    onSuccess: () => {
      // Cache invalidieren
      utils.user.getMe.invalidate();
    },
    onError: (error) => {
      // Zod Errors sind typisiert
      if (error.data?.zodError) {
        console.log(error.data.zodError.fieldErrors);
      }
    }
  });

  const handleSubmit = (data: FormData) => {
    updateUser.mutate({
      name: data.get('name') as string
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button disabled={updateUser.isPending}>
        {updateUser.isPending ? 'Speichern...' : 'Speichern'}
      </button>
    </form>
  );
}

Optimistic Updates

function TodoList() {
  const utils = trpc.useUtils();

  const addTodo = trpc.todo.create.useMutation({
    // Optimistic Update
    onMutate: async (newTodo) => {
      await utils.todo.list.cancel();

      const previousTodos = utils.todo.list.getData();

      utils.todo.list.setData(undefined, (old) => [
        ...(old ?? []),
        { id: 'temp', ...newTodo, createdAt: new Date() }
      ]);

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      utils.todo.list.setData(undefined, context?.previousTodos);
    },
    onSettled: () => {
      utils.todo.list.invalidate();
    }
  });

  return (/* ... */);
}

Server-Side Rendering

// src/app/users/[id]/page.tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '@/server/routers/_app';
import superjson from 'superjson';

export default async function UserPage({
  params
}: {
  params: { id: string }
}) {
  const helpers = createServerSideHelpers({
    router: appRouter,
    ctx: await createContext(),
    transformer: superjson
  });

  // Prefetch on Server
  await helpers.user.getById.prefetch(params.id);

  return (
    <HydrationBoundary state={helpers.dehydrate()}>
      <UserProfile userId={params.id} />
    </HydrationBoundary>
  );
}

Subscriptions (WebSocket)

// Server
export const chatRouter = router({
  onMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(async function* ({ input }) {
      // Async Generator für Subscription
      for await (const message of messageStream(input.roomId)) {
        yield message;
      }
    })
});

// Client
function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  trpc.chat.onMessage.useSubscription(
    { roomId },
    {
      onData: (message) => {
        setMessages(prev => [...prev, message]);
      }
    }
  );

  return (/* ... */);
}

Error Handling

// Server-Side Error
import { TRPCError } from '@trpc/server';

export const postRouter = router({
  delete: protectedProcedure
    .input(z.string())
    .mutation(async ({ ctx, input }) => {
      const post = await prisma.post.findUnique({
        where: { id: input }
      });

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Post nicht gefunden'
        });
      }

      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Keine Berechtigung'
        });
      }

      return prisma.post.delete({ where: { id: input } });
    })
});

// Client-Side Handling
const deletePost = trpc.post.delete.useMutation({
  onError: (error) => {
    switch (error.data?.code) {
      case 'NOT_FOUND':
        toast.error('Post existiert nicht');
        break;
      case 'FORBIDDEN':
        toast.error('Keine Berechtigung');
        break;
      default:
        toast.error('Ein Fehler ist aufgetreten');
    }
  }
});

Fazit

tRPC bietet:

  1. Zero-Config Type Safety: TypeScript-Types automatisch geteilt
  2. Keine Code-Generierung: Kein GraphQL Codegen, kein OpenAPI
  3. React Query Integration: Caching, Optimistic Updates built-in
  4. Zod Validation: Runtime + Compile-Time Safety

Für TypeScript-Monorepos ist tRPC die perfekte API-Lösung.


Bildprompts

  1. "TypeScript types flowing seamlessly between server and client, connected code blocks"
  2. "API calls with automatic type completion, developer productivity visualization"
  3. "Bridge connecting frontend and backend with TypeScript logo, full-stack concept"

Quellen