DevToolBoxGRATIS
Blog

tRPC Complete Guide: End-to-End Type-Safe APIs for TypeScript

22 min readby DevToolBox Team

TL;DR

tRPC lets you build fully type-safe APIs without writing API schemas, code generation, or REST/GraphQL boilerplate. You define server procedures and call them from your client with full autocompletion and type checking. Changes to your API are caught by TypeScript at compile time, not by your users in production. It integrates natively with Zod for validation and React Query for data fetching.

Key Takeaways

  • tRPC provides end-to-end type safety from server to client without schemas or code generation
  • Built on top of React Query, giving you caching, deduplication, and background refetching for free
  • Zod integration makes input validation type-safe and declarative
  • Middleware and context enable authentication, authorization, and request-scoped data
  • Works with Next.js App Router, Express, Fastify, and other frameworks
  • Subscriptions via WebSockets for real-time features

What Is tRPC?

tRPC (TypeScript Remote Procedure Call) is a library that enables you to build type-safe APIs for TypeScript applications. Unlike REST or GraphQL, tRPC requires no API schema definition, no code generation step, and no runtime overhead for type checking. Your server-side procedure definitions become the single source of truth, and TypeScript ensures your client calls match exactly.

With tRPC, when you rename a field on the server, TypeScript immediately flags every client usage that needs updating. When you add a required input parameter, the client code fails to compile until it provides it. This eliminates an entire class of bugs that traditionally plague full-stack development.

// Traditional REST workflow:
// 1. Define REST routes on server
// 2. Write fetch calls on client
// 3. Manually type the response
// 4. Hope they stay in sync

// tRPC workflow:
// 1. Define a procedure on the server
const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
});

// 2. Call it from the client β€” fully typed!
const { data } = trpc.getUser.useQuery({ id: "123" });
// data is automatically typed as User | null
// TypeScript error if you pass wrong input shape

Setting Up tRPC with Next.js

The most common tRPC setup uses Next.js with the App Router. Here is how to install and configure everything from scratch.

Installation

npm install @trpc/server @trpc/client @trpc/react-query \
  @trpc/next @tanstack/react-query zod superjson

Initialize the tRPC Server

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

export const createTRPCContext = async (opts: {
  headers: Headers;
}) => {
  const session = await getServerSession();
  return {
    db: prisma,
    session,
    headers: opts.headers,
  };
};

const t = initTRPC.context<typeof createTRPCContext>().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;
export const createCallerFactory = t.createCallerFactory;

Create the API Route Handler (Next.js App Router)

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

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

export { handler as GET, handler as POST };

Set Up the Client Provider

// src/lib/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 "@/lib/trpc";
import superjson from "superjson";
import { useState } from "react";

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 5 * 60 * 1000 },
    },
  }));
  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>
  );
}

Setting Up tRPC with Express

tRPC also works as a standalone Express middleware, which is useful if you are not using Next.js.

// server.ts
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./routers/_app";
import { createTRPCContext } from "./trpc";

const app = express();

app.use(
  "/api/trpc",
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext: ({ req, res }) => ({
      db: prisma,
      session: req.session,
    }),
  })
);

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

Routers and Procedures

Routers organize your API into logical groups. Procedures are the individual endpoints within a router. There are three types of procedures: queries (read data), mutations (write data), and subscriptions (real-time streams).

// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";

export const userRouter = router({
  // Query: fetch data (GET-like)
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.db.user.findUnique({
        where: { id: input.id },
      });
    }),

  // Query: list with pagination
  list: publicProcedure
    .input(z.object({
      page: z.number().int().min(1).default(1),
      limit: z.number().int().min(1).max(100).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const { page, limit } = input;
      const [users, total] = await Promise.all([
        ctx.db.user.findMany({
          skip: (page - 1) * limit,
          take: limit,
        }),
        ctx.db.user.count(),
      ]);
      return { users, total, pages: Math.ceil(total / limit) };
    }),

  // Mutation: write data (POST/PUT/DELETE-like)
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(1).max(100).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.update({
        where: { id: input.id },
        data: input,
      });
    }),
});

Nested Routers

You can nest routers to create a hierarchical API structure. This keeps your code organized as your API grows.

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

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

// Export the type for the client
export type AppRouter = typeof appRouter;

// Client usage:
// trpc.user.getById.useQuery({ id: "123" })
// trpc.post.list.useQuery({ page: 1 })
// trpc.comment.create.useMutation()

Input Validation with Zod

tRPC integrates with Zod for input validation. When you define an input schema, tRPC validates incoming data at runtime and infers the TypeScript type for your procedure handler automatically.

import { z } from "zod";
import { router, publicProcedure } from "../trpc";

export const productRouter = router({
  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1, "Name is required").max(200),
        price: z.number().positive("Price must be positive"),
        category: z.enum(["electronics", "clothing", "food"]),
        tags: z.array(z.string()).max(10).default([]),
        metadata: z.record(z.string(), z.unknown()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input is fully typed:
      // {
      //   name: string;
      //   price: number;
      //   category: "electronics" | "clothing" | "food";
      //   tags: string[];
      //   metadata?: Record<string, unknown>;
      // }
      return ctx.db.product.create({ data: input });
    }),
});

Complex Input Schemas

// Reusable input schemas
const PaginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().int().min(1).max(100).default(20),
});

const DateRangeInput = z.object({
  from: z.date(),
  to: z.date(),
}).refine(
  (data) => data.from <= data.to,
  { message: "Start date must be before end date" }
);

const SearchInput = z.object({
  query: z.string().min(1).max(500),
  filters: z.object({
    category: z.enum(["all", "posts", "users"]).default("all"),
    dateRange: DateRangeInput.optional(),
    sortBy: z.enum(["relevance", "date", "popularity"]).default("relevance"),
  }).default({}),
  pagination: PaginationInput.default({}),
});

export const searchRouter = router({
  search: publicProcedure
    .input(SearchInput)
    .query(async ({ ctx, input }) => {
      // All fields are validated and typed
      const { query, filters, pagination } = input;
      return ctx.db.search(query, filters, pagination);
    }),
});

Queries and Mutations

Queries fetch data and are called with useQuery on the client. Mutations modify data and are called with useMutation. Both are fully type-safe.

Calling from the Client

// Using queries in React components
function UserProfile({ userId }: { userId: string }) {
  // Fully typed query β€” data type is inferred from server
  const { data: user, isLoading, error } = trpc.user.getById.useQuery(
    { id: userId },
    { enabled: !!userId }
  );

  // Fully typed mutation
  const updateUser = trpc.user.update.useMutation({
    onSuccess: () => {
      // Invalidate and refetch the query after mutation
      utils.user.getById.invalidate({ id: userId });
    },
    onError: (err) => {
      // err.data?.zodError has field-level errors
      console.error("Update failed:", err.message);
    },
  });

  const utils = trpc.useUtils();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      updateUser.mutate({
        id: userId,
        name: "New Name",  // TypeScript ensures valid fields
      });
    }}>
      <p>{user.name}</p>
      <p>{user.email}</p>
      <button type="submit" disabled={updateUser.isPending}>
        {updateUser.isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Middleware

Middleware lets you run shared logic before your procedures execute. Common uses include authentication checks, logging, rate limiting, and timing. Middleware can modify the context that gets passed to the procedure.

Authentication Middleware

// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in",
    });
  }
  // Add user to context for downstream procedures
  return next({
    ctx: {
      session: ctx.session,
      user: ctx.session.user,  // Now guaranteed to exist
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

// Logging middleware
const logger = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(
    `[tRPC] \${type} \${path} - \${duration}ms - ` +
    `\${result.ok ? "OK" : "ERROR"}`
  );
  return result;
});

// Role-based middleware
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (ctx.session?.user?.role !== "admin") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
  return next({ ctx });
});

export const adminProcedure = protectedProcedure.use(isAdmin);

Context

Context is data that is available to all procedures and middleware. It is created per-request and typically includes the database connection, the authenticated user, and other request-scoped state.

// src/server/context.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

export const createTRPCContext = async (opts: {
  headers: Headers;
}) => {
  const session = await getServerSession(authOptions);

  return {
    // Database client β€” shared across all procedures
    db: prisma,
    // Authenticated session (null if not logged in)
    session,
    // Request headers for IP, user-agent, etc.
    headers: opts.headers,
    // Request ID for tracing
    requestId: crypto.randomUUID(),
  };
};

export type Context = Awaited<ReturnType<typeof createTRPCContext>>;

// Procedures access context via ctx:
// .query(async ({ ctx }) => {
//   ctx.db       β€” Prisma client
//   ctx.session  β€” Auth session
//   ctx.headers  β€” Request headers
// })

Error Handling

tRPC provides a TRPCError class for throwing structured errors with HTTP status codes. These errors are serialized and sent to the client where they can be caught and handled.

import { TRPCError } from "@trpc/server";

export const postRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });

      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Post not found",
        });
      }

      // Check permission
      if (post.isPrivate && post.authorId !== ctx.session?.user?.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You do not have permission to view this post",
        });
      }

      return post;
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      try {
        return await ctx.db.post.delete({
          where: { id: input.id, authorId: ctx.user.id },
        });
      } catch (e) {
        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Failed to delete post",
          cause: e,
        });
      }
    }),
});

// Available error codes:
// PARSE_ERROR          400
// BAD_REQUEST          400
// UNAUTHORIZED         401
// FORBIDDEN            403
// NOT_FOUND            404
// METHOD_NOT_SUPPORTED 405
// TIMEOUT              408
// CONFLICT             409
// TOO_MANY_REQUESTS    429
// INTERNAL_SERVER_ERROR 500

Custom Error Formatting

// Custom error formatting in initTRPC
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Include Zod validation errors
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

// Client-side error handling
const mutation = trpc.user.update.useMutation({
  onError: (error) => {
    if (error.data?.zodError) {
      // Field-level validation errors
      const fieldErrors = error.data.zodError.fieldErrors;
      // { name: ["Too short"], email: ["Invalid email"] }
    } else {
      // General server error
      toast.error(error.message);
    }
  },
});

React Query Integration

tRPC wraps React Query (TanStack Query) to provide type-safe data fetching hooks. You get all React Query features like caching, background refetching, optimistic updates, and infinite queries with full type inference.

Optimistic Updates

function TodoList() {
  const utils = trpc.useUtils();
  const todos = trpc.todo.list.useQuery();

  const toggleTodo = trpc.todo.toggle.useMutation({
    // Optimistically update the UI before server responds
    onMutate: async (input) => {
      // Cancel any outgoing refetches
      await utils.todo.list.cancel();

      // Snapshot the previous value
      const previousTodos = utils.todo.list.getData();

      // Optimistically update the cache
      utils.todo.list.setData(undefined, (old) =>
        old?.map((t) =>
          t.id === input.id
            ? { ...t, completed: !t.completed }
            : t
        )
      );

      return { previousTodos };
    },
    // If mutation fails, roll back to snapshot
    onError: (err, input, context) => {
      utils.todo.list.setData(undefined, context?.previousTodos);
    },
    // Always refetch after error or success
    onSettled: () => {
      utils.todo.list.invalidate();
    },
  });

  return (
    <ul>
      {todos.data?.map((todo) => (
        <li key={todo.id} onClick={() => toggleTodo.mutate({ id: todo.id })}>
          {todo.completed ? "[x]" : "[ ]"} {todo.text}
        </li>
      ))}
    </ul>
  );
}

Infinite Queries

// Server: cursor-based pagination
export const feedRouter = router({
  infiniteFeed: publicProcedure
    .input(z.object({
      cursor: z.string().nullish(),
      limit: z.number().min(1).max(50).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const items = await ctx.db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: "desc" },
      });
      let nextCursor: string | undefined;
      if (items.length > input.limit) {
        const nextItem = items.pop();
        nextCursor = nextItem?.id;
      }
      return { items, nextCursor };
    }),
});

// Client: infinite scroll
function Feed() {
  const feed = trpc.feed.infiniteFeed.useInfiniteQuery(
    { limit: 20 },
    { getNextPageParam: (lastPage) => lastPage.nextCursor }
  );

  return (
    <div>
      {feed.data?.pages.map((page) =>
        page.items.map((item) => (
          <PostCard key={item.id} post={item} />
        ))
      )}
      <button
        onClick={() => feed.fetchNextPage()}
        disabled={!feed.hasNextPage || feed.isFetchingNextPage}
      >
        {feed.isFetchingNextPage ? "Loading..." : "Load more"}
      </button>
    </div>
  );
}

Subscriptions (Real-Time)

Subscriptions use WebSockets to push data from the server to the client in real time. They are useful for chat applications, live dashboards, notifications, and collaborative editing.

// Server: WebSocket subscription
import { observable } from "@trpc/server/observable";
import { EventEmitter } from "events";

const ee = new EventEmitter();

export const chatRouter = router({
  sendMessage: protectedProcedure
    .input(z.object({
      roomId: z.string(),
      text: z.string().min(1).max(1000),
    }))
    .mutation(async ({ ctx, input }) => {
      const message = {
        id: crypto.randomUUID(),
        text: input.text,
        roomId: input.roomId,
        userId: ctx.user.id,
        createdAt: new Date(),
      };
      await ctx.db.message.create({ data: message });
      ee.emit("newMessage", message);
      return message;
    }),

  onNewMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(({ input }) => {
      return observable((emit) => {
        const handler = (message: Message) => {
          if (message.roomId === input.roomId) {
            emit.next(message);
          }
        };
        ee.on("newMessage", handler);
        return () => ee.off("newMessage", handler);
      });
    }),
});

// Client: subscribe to messages
function ChatRoom({ roomId }: { roomId: string }) {
  trpc.chat.onNewMessage.useSubscription(
    { roomId },
    {
      onData: (message) => {
        // Append new message to local state
        setMessages((prev) => [...prev, message]);
      },
      onError: (err) => {
        console.error("Subscription error:", err);
      },
    }
  );
}

Testing tRPC Procedures

You can test tRPC procedures directly by calling the router without an HTTP layer. This makes unit tests fast and simple.

// __tests__/user.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { appRouter } from "@/server/routers/_app";
import { createCallerFactory } from "@/server/trpc";
import { prisma } from "@/lib/__mocks__/prisma";

const createCaller = createCallerFactory(appRouter);

describe("user router", () => {
  it("should return a user by ID", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,
      headers: new Headers(),
    });

    prisma.user.findUnique.mockResolvedValue({
      id: "1",
      name: "Alice",
      email: "alice@example.com",
    });

    const user = await caller.user.getById({ id: "1" });
    expect(user).toEqual({
      id: "1",
      name: "Alice",
      email: "alice@example.com",
    });
  });

  it("should throw NOT_FOUND for missing user", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,
      headers: new Headers(),
    });

    prisma.user.findUnique.mockResolvedValue(null);

    await expect(
      caller.user.getById({ id: "nonexistent" })
    ).rejects.toThrow("NOT_FOUND");
  });

  it("should reject invalid input", async () => {
    const caller = createCaller({
      db: prisma,
      session: { user: { id: "1", role: "user" } },
      headers: new Headers(),
    });

    await expect(
      caller.user.update({ id: "1", email: "not-an-email" })
    ).rejects.toThrow();
  });

  it("should require auth for protected routes", async () => {
    const caller = createCaller({
      db: prisma,
      session: null,  // Not logged in
      headers: new Headers(),
    });

    await expect(
      caller.user.update({ id: "1", name: "Hacker" })
    ).rejects.toThrow("UNAUTHORIZED");
  });
});

Performance Tips

tRPC is already fast because it skips schema parsing and code generation, but there are additional optimizations you can apply.

  • Use HTTP batching: tRPC batches multiple requests into a single HTTP call by default, reducing network overhead
  • Enable transformer: Use superjson to serialize Dates, Maps, Sets, and other non-JSON types without manual conversion
  • Leverage React Query caching: Configure staleTime and gcTime to avoid unnecessary refetches
  • Use server-side calls: In Next.js Server Components, call procedures directly without HTTP overhead using createCallerFactory
  • Split routers: Use dynamic imports for large routers to reduce bundle size
  • Connection pooling: Reuse database connections in context creation instead of opening new ones per request
// Server-side calls in Next.js Server Components
// (no HTTP overhead β€” direct function call)
import { createCallerFactory } from "@/server/trpc";
import { appRouter } from "@/server/routers/_app";

const createCaller = createCallerFactory(appRouter);

export default async function UserPage({
  params,
}: {
  params: { id: string };
}) {
  const caller = createCaller(await createTRPCContext({
    headers: headers(),
  }));

  // Direct procedure call β€” no HTTP request
  const user = await caller.user.getById({ id: params.id });

  return <UserProfileView user={user} />;
}

Frequently Asked Questions

How does tRPC differ from REST and GraphQL?

REST requires manually maintaining API contracts and has no built-in type safety. GraphQL provides a schema but requires code generation to get TypeScript types. tRPC skips both the schema definition and code generation by using TypeScript inference directly. Your server code IS your API contract. The trade-off is that tRPC only works with TypeScript clients, while REST and GraphQL are language-agnostic.

Can I use tRPC with a non-TypeScript client (mobile app, Python)?

tRPC is designed for TypeScript-to-TypeScript communication. For non-TypeScript clients, you can expose a REST or GraphQL API alongside tRPC, or use trpc-openapi to generate an OpenAPI spec from your tRPC router that any HTTP client can consume.

Does tRPC replace React Query?

No, tRPC is built on top of React Query (TanStack Query). It uses React Query under the hood for caching, background refetching, optimistic updates, and all other React Query features. tRPC adds type-safe procedure calls on top of that foundation.

How do I handle file uploads with tRPC?

tRPC does not natively handle multipart form data for file uploads. You can handle file uploads through a separate REST endpoint or use a presigned URL approach where tRPC returns a signed upload URL and the client uploads directly to cloud storage (S3, R2, etc).

Can I use tRPC without Next.js?

Yes. tRPC has adapters for Express, Fastify, standalone Node.js HTTP server, AWS Lambda, Cloudflare Workers, and more. Next.js is the most common setup but not required. You can use any Node.js server framework.

Is tRPC production-ready?

Yes. tRPC v11 is stable and used in production by companies of all sizes. It has been battle-tested since 2021 and is maintained actively by the core team and community contributors.

How do I version my tRPC API?

Since tRPC is typically used within a single TypeScript monorepo, versioning is handled by your deployment process. If you need versioning for external consumers, use trpc-openapi to expose versioned REST endpoints alongside your tRPC procedures.

Does tRPC support server-side rendering?

Yes. In Next.js, you can prefetch tRPC queries on the server using the createCaller API or React Server Components. The data is serialized to the client where React Query hydrates its cache, avoiding duplicate fetches on mount.

𝕏 Twitterin LinkedIn
Was dit nuttig?

Blijf op de hoogte

Ontvang wekelijkse dev-tips en nieuwe tools.

Geen spam. Altijd opzegbaar.

Try These Related Tools

{ }JSON Formatterβœ“JSON Validator

Related Articles

Hono Complete Guide: Ultra-Fast Web Framework for Edge and Beyond

Master Hono with routing, middleware, Zod validation, JWT auth, CORS, OpenAPI, RPC mode, and multi-runtime support for Cloudflare Workers, Deno, Bun, and Node.js.

Next.js App Router: Complete migratiegids 2026

Beheers de Next.js App Router met deze uitgebreide gids. Server Components, data fetching, layouts, streaming, Server Actions en stapsgewijze migratie van Pages Router.

TypeScript Generics Complete Gids 2026: Van Basis tot Geavanceerde Patronen

Beheers TypeScript generics: typeparameters, constraints, conditionele types, mapped types, utility types en praktijkpatronen.