تخطى إلى المحتوى
·24 دقيقة قراءة

tRPC: أمان نوعي شامل بدون التكلفات

كيف يقضي tRPC على مشكلة عقد API، ويعمل مع Next.js App Router، ويتعامل مع وسيط المصادقة، ورفع الملفات، والاشتراكات. إعداد tRPC واقعي من الصفر.

مشاركة:X / TwitterLinkedIn

تعرف السيناريو. تغيّر اسم حقل في استجابة API. تحدّث نوع الخلفية. تنشر. ثم الواجهة الأمامية تتعطل في الإنتاج لأن شخصاً نسي تحديث استدعاء fetch في السطر 247 من Dashboard.tsx. الحقل الآن undefined، والمكوّن يعرض فارغاً، ونظام تتبع الأخطاء يشتعل في الساعة 2 صباحاً.

هذه هي مشكلة عقد API. ليست مشكلة تقنية. إنها مشكلة تنسيق. ولن يصلح أي قدر من وثائق Swagger أو مخططات GraphQL حقيقة أن أنواع الواجهة الأمامية والخلفية يمكن أن تنحرف عن بعضها بصمت.

tRPC يصلح هذا برفض السماح لها بالانحراف. لا يوجد ملف مخطط. لا خطوة توليد كود. لا عقد منفصل للصيانة. تكتب دالة TypeScript على الخادم، والعميل يعرف أنواع المدخلات والمخرجات بالضبط في وقت التجميع. إذا أعدت تسمية حقل، الواجهة الأمامية لن تُجمّع حتى تصلحه.

هذا هو الوعد. دعني أُريك كيف يعمل فعلاً، وأين يتألق، وأين يجب ألا تستخدمه بالتأكيد.

المشكلة، بدقة أكبر#

لنلقِ نظرة على كيف تبني معظم الفرق واجهات API اليوم.

REST + OpenAPI: تكتب نقاط النهاية. ربما تضيف تعليقات Swagger. ربما تولّد SDK للعميل من مواصفة OpenAPI. لكن المواصفة هي عنصر منفصل. يمكن أن تصبح قديمة. خطوة التوليد هي شيء آخر في خط CI/CD يمكن أن ينكسر أو يُنسى. والأنواع المولّدة غالباً قبيحة — وحوش متداخلة بعمق مثل paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].

GraphQL: أمان نوعي أفضل، لكن تكلفة إجرائية هائلة. تكتب مخططاً بـ SDL. تكتب المحلّلات. تولّد الأنواع من المخطط. تكتب الاستعلامات على العميل. تولّد الأنواع من الاستعلامات. هذا خطوتا توليد كود على الأقل، وملف مخطط، وخطوة بناء يجب أن يتذكرها الجميع. لفريق يتحكم بالفعل في الواجهة الأمامية والخلفية، هذه بنية تحتية كبيرة لمشكلة لها حل أبسط.

استدعاءات fetch يدوية: النهج الأكثر شيوعاً والأكثر خطورة. تكتب fetch("/api/users")، وتحوّل النتيجة إلى User[]، وتأمل الأفضل. لا يوجد أمان في وقت التجميع. تأكيد النوع هو كذبة تخبرها لـ TypeScript.

typescript
// الكذبة التي أخبرها كل مطور واجهة أمامية
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // نأمل أن هذا صحيح

tRPC يتخذ نهجاً مختلفاً تماماً. بدلاً من وصف API بصيغة منفصلة وتوليد الأنواع، تكتب دوال TypeScript عادية على الخادم وتستورد أنواعها مباشرة في العميل. لا خطوة توليد. لا ملف مخطط. لا انحراف.

المفاهيم الأساسية#

قبل أن نُعدّ أي شيء، لنفهم النموذج الذهني.

الموجّه (Router)#

موجّه tRPC هو مجموعة من الإجراءات مُجمّعة معاً. فكّر فيه كمتحكم في MVC، إلا أنه مجرد كائن عادي مع استنتاج النوع مدمجاً.

typescript
import { initTRPC } from "@trpc/server";
 
const t = initTRPC.create();
 
export const appRouter = t.router({
  user: t.router({
    list: t.procedure.query(/* ... */),
    byId: t.procedure.input(/* ... */).query(/* ... */),
    create: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
  post: t.router({
    list: t.procedure.query(/* ... */),
    publish: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
});
 
export type AppRouter = typeof appRouter;

تصدير النوع AppRouter هذا هو كل السحر. العميل يستورد هذا النوع — ليس الكود التشغيلي، فقط النوع — ويحصل على إكمال تلقائي كامل وفحص نوع لكل إجراء.

الإجراء (Procedure)#

الإجراء هو نقطة نهاية واحدة. هناك ثلاثة أنواع:

  • استعلام (Query): عمليات القراءة. يتوافق مع دلالات HTTP GET. يُخزّن مؤقتاً بواسطة TanStack Query.
  • تحويل (Mutation): عمليات الكتابة. يتوافق مع HTTP POST. لا يُخزّن مؤقتاً.
  • اشتراك (Subscription): تدفقات في الوقت الحقيقي. يستخدم WebSockets.

السياق (Context)#

السياق هو البيانات المحددة بالطلب المتاحة لكل إجراء. اتصالات قاعدة البيانات، والمستخدم المصادق عليه، ورؤوس الطلب — أي شيء كنت ستضعه في كائن req في Express يذهب هنا.

الوسيط (Middleware)#

الوسيط يحوّل السياق أو يحرس الوصول. النمط الأكثر شيوعاً هو وسيط مصادقة يتحقق من جلسة صالحة ويضيف ctx.user.

سلسلة استنتاج النوع#

هذا هو النموذج الذهني الحاسم. عندما تعرّف إجراءً هكذا:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input مكتوب كـ { id: string }
    return db.user.findUnique({ where: { id: input.id } });
  });

نوع الإرجاع يتدفق طوال الطريق إلى العميل. إذا أعاد db.user.findUnique النوع User | null، فإن خطاف useQuery للعميل سيكون data مكتوباً كـ User | null. لا كتابة يدوية. لا تحويل. يُستنتج من طرف إلى طرف.

الإعداد مع Next.js App Router#

لنبنِ هذا من الصفر. سأفترض أن لديك مشروع Next.js 14+ مع App Router.

تثبيت التبعيات#

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

الخطوة 1: تهيئة tRPC على الخادم#

أنشئ نسخة tRPC وعرّف نوع السياق.

typescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
 
  return {
    session,
    db: prisma,
    req: opts.req,
  };
}
 
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 createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

بعض الأشياء يجب ملاحظتها:

  • محوّل superjson: tRPC يسلسل البيانات كـ JSON افتراضياً، مما يعني أن كائنات Date، وMap، وSet، والأنواع غير JSON الأخرى تُفقد. superjson يحافظ عليها.
  • مُنسّق الأخطاء: نربط أخطاء تحقق Zod بالاستجابة حتى يتمكن العميل من عرض أخطاء على مستوى الحقل.
  • createTRPCContext: هذه الدالة تعمل على كل طلب. هنا تحلل الجلسة، وتُعدّ اتصال قاعدة البيانات، وتبني كائن السياق.

الخطوة 2: تعريف الموجّه#

typescript
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
 
export const userRouter = router({
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        name: true,
        email: true,
        image: true,
        createdAt: true,
      },
    });
 
    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }
 
    return user;
  }),
 
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        bio: z.string().max(500).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.user.id },
        data: {
          name: input.name,
          bio: input.bio,
        },
      });
 
      return updated;
    }),
 
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          image: true,
          bio: true,
        },
      });
 
      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "User not found",
        });
      }
 
      return user;
    }),
});
typescript
// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
 
export const appRouter = router({
  user: userRouter,
  post: postRouter,
  notification: notificationRouter,
});
 
export type AppRouter = typeof appRouter;

الخطوة 3: الكشف عبر معالج المسار في Next.js#

في App Router، يعمل tRPC كمعالج مسار قياسي. لا خادم مخصص، لا إضافة Next.js خاصة.

typescript
// 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,
    onError:
      process.env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

هذا كل شيء. كل من GET و POST يُعالجان. الاستعلامات تمر عبر GET (مع مدخلات مُرمّزة في URL)، والتحويلات تمر عبر POST.

الخطوة 4: إعداد العميل#

typescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
 
export const trpc = createTRPCReact<AppRouter>();

ملاحظة: نستورد AppRouter كـ نوع فقط. لا يتسرب كود الخادم إلى حزمة العميل.

الخطوة 5: إعداد المزوّد#

typescript
// src/components/providers/TRPCProvider.tsx
"use client";
 
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
 
function getBaseUrl() {
  if (typeof window !== "undefined") return "";
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}
 
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            retry: 1,
          },
        },
      })
  );
 
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
typescript
// src/app/layout.tsx (الجزء ذو الصلة)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

الخطوة 6: الاستخدام في المكوّنات#

typescript
// src/app/dashboard/page.tsx
"use client";
 
import { trpc } from "@/lib/trpc";
 
export default function DashboardPage() {
  const { data: user, isLoading, error } = trpc.user.me.useQuery();
 
  if (isLoading) return <DashboardSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  return (
    <div>
      <h1>مرحباً بعودتك، {user.name}</h1>
      <p>عضو منذ {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

user.name هذا مكتوب بالكامل. إذا أخطأت في كتابته كـ user.nme، TypeScript يلتقطه فوراً. إذا غيّرت الخادم ليُعيد displayName بدلاً من name، كل استخدام في العميل سيُظهر خطأ تجميع. لا مفاجآت وقت التشغيل.

السياق والوسيط#

السياق والوسيط هما حيث ينتقل tRPC من "خدعة نوع أنيقة" إلى "إطار جاهز للإنتاج."

إنشاء السياق#

دالة السياق تعمل على كل طلب. إليك نسخة واقعية:

typescript
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getServerSession(authOptions);
 
  return {
    session,
    user: session?.user ?? null,
    db: prisma,
    headers: Object.fromEntries(opts.req.headers),
  };
}
 
type Context = Awaited<ReturnType<typeof createTRPCContext>>;

وسيط المصادقة#

نمط الوسيط الأكثر شيوعاً هو فصل الإجراءات العامة عن المحمية:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }
 
  return next({
    ctx: {
      // تجاوز نوع السياق — المستخدم لم يعد قابلاً للإلغاء
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

بعد تشغيل هذا الوسيط، ctx.user في أي protectedProcedure مضمون أن يكون غير فارغ. نظام النوع يفرض هذا. لا يمكنك الوصول عن طريق الخطأ إلى ctx.user.id في إجراء عام بدون أن يشتكي TypeScript.

وسيط الأدوار#

يمكنك تركيب الوسطاء لتحكم وصول أكثر دقة:

typescript
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
 
  if (ctx.user.role !== "ADMIN") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
 
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});
 
export const adminProcedure = t.procedure.use(isAdmin);

وسيط التسجيل#

الوسيط ليس للمصادقة فقط. إليك وسيط تسجيل الأداء:

typescript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
 
  const result = await next();
 
  const duration = Date.now() - start;
 
  if (duration > 1000) {
    console.warn(`${type} بطيء ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// تطبيق على جميع الإجراءات
export const publicProcedure = t.procedure.use(loggerMiddleware);

وسيط تحديد المعدل#

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "10 s"),
});
 
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
  const { success, remaining } = await ratelimit.limit(ip);
 
  if (!success) {
    throw new TRPCError({
      code: "TOO_MANY_REQUESTS",
      message: `تم تجاوز حد المعدل. حاول مرة أخرى لاحقاً.`,
    });
  }
 
  return next();
});

التحقق من المدخلات مع Zod#

يستخدم tRPC مكتبة Zod للتحقق من المدخلات. هذا ليس زخرفة اختيارية — إنه الآلية التي تضمن أمان المدخلات على كل من العميل والخادم.

التحقق الأساسي#

typescript
const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, "العنوان مطلوب").max(200, "العنوان طويل جداً"),
        content: z.string().min(10, "المحتوى يجب أن يكون 10 أحرف على الأقل"),
        categoryId: z.string().uuid("معرف الفئة غير صالح"),
        tags: z.array(z.string()).max(5, "5 وسوم كحد أقصى").default([]),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // المدخل مكتوب بالكامل:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

خدعة التحقق المزدوج#

إليك شيئاً دقيقاً: تحقق Zod يعمل على كلا الجانبين. على العميل، يتحقق tRPC من المدخل قبل إرسال الطلب. إذا كان المدخل غير صالح، الطلب لا يغادر المتصفح أبداً. على الخادم، نفس المخطط يتحقق مرة أخرى كإجراء أمني.

هذا يعني أنك تحصل على تحقق فوري من جانب العميل مجاناً:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
 
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
    },
    onError: (error) => {
      // أخطاء Zod تصل منظمة
      if (error.data?.zodError) {
        const fieldErrors = error.data.zodError.fieldErrors;
        // fieldErrors.title?: string[]
        // fieldErrors.content?: string[]
        console.log(fieldErrors);
      }
    },
  });
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({
          title,
          content: "...",
          categoryId: "...",
        });
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {createPost.error?.data?.zodError?.fieldErrors.title && (
        <span className="text-red-500">
          {createPost.error.data.zodError.fieldErrors.title[0]}
        </span>
      )}
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? "جارٍ الإنشاء..." : "إنشاء مقال"}
      </button>
    </form>
  );
}

أنماط المدخلات المعقدة#

typescript
// الاتحادات المميّزة
const searchInput = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    query: z.string(),
    includeInactive: z.boolean().default(false),
  }),
  z.object({
    type: z.literal("post"),
    query: z.string(),
    category: z.string().optional(),
  }),
]);
 
// مدخل الترقيم المعاد استخدامه عبر الإجراءات
const paginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().min(1).max(100).default(20),
});
 
const postRouter = router({
  infiniteList: publicProcedure
    .input(
      z.object({
        ...paginationInput.shape,
        category: z.string().optional(),
        sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
      })
    )
    .query(async ({ ctx, input }) => {
      const { cursor, limit, category, sortBy } = input;
 
      const posts = await ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: category ? { categoryId: category } : undefined,
        orderBy:
          sortBy === "newest"
            ? { createdAt: "desc" }
            : sortBy === "popular"
              ? { likes: "desc" }
              : { score: "desc" },
      });
 
      let nextCursor: string | undefined;
      if (posts.length > limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }
 
      return {
        posts,
        nextCursor,
      };
    }),
});

المدخلات الاختيارية#

ليس كل إجراء يحتاج مدخلاً. الاستعلامات غالباً لا تحتاج:

typescript
const statsRouter = router({
  // لا مدخل مطلوب
  overview: publicProcedure.query(async ({ ctx }) => {
    const [userCount, postCount, commentCount] = await Promise.all([
      ctx.db.user.count(),
      ctx.db.post.count(),
      ctx.db.comment.count(),
    ]);
 
    return { userCount, postCount, commentCount };
  }),
 
  // فلاتر اختيارية
  detailed: publicProcedure
    .input(
      z
        .object({
          from: z.date().optional(),
          to: z.date().optional(),
        })
        .optional()
    )
    .query(async ({ ctx, input }) => {
      const where = {
        ...(input?.from && { createdAt: { gte: input.from } }),
        ...(input?.to && { createdAt: { lte: input.to } }),
      };
 
      return ctx.db.post.groupBy({
        by: ["categoryId"],
        where,
        _count: true,
      });
    }),
});

معالجة الأخطاء#

معالجة أخطاء tRPC منظمة، وآمنة نوعياً، وتتكامل بسلاسة مع كل من دلالات HTTP وواجهة المستخدم على جانب العميل.

رمي الأخطاء على الخادم#

typescript
import { TRPCError } from "@trpc/server";
 
const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });
 
      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "المقال غير موجود",
        });
      }
 
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "يمكنك حذف مقالاتك فقط",
        });
      }
 
      await ctx.db.post.delete({ where: { id: input.id } });
 
      return { success: true };
    }),
});

أكواد أخطاء tRPC تتوافق مع أكواد حالة HTTP:

كود tRPCحالة HTTPمتى تستخدم
BAD_REQUEST400مدخل غير صالح بخلاف تحقق Zod
UNAUTHORIZED401غير مسجّل الدخول
FORBIDDEN403مسجّل الدخول لكن صلاحيات غير كافية
NOT_FOUND404المورد غير موجود
CONFLICT409مورد مكرر
TOO_MANY_REQUESTS429تجاوز حد المعدل
INTERNAL_SERVER_ERROR500خطأ خادم غير متوقع

تنسيق أخطاء مخصص#

هل تذكر مُنسّق الأخطاء من إعدادنا؟ إليك كيف يعمل عملياً:

typescript
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,
        // إضافة حقول مخصصة
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

معالجة الأخطاء على جانب العميل#

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
 
export function DeletePostButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const deletePost = trpc.post.delete.useMutation({
    onSuccess: () => {
      toast.success("تم حذف المقال");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("ليس لديك صلاحية لحذف هذا المقال");
          break;
        case "NOT_FOUND":
          toast.error("هذا المقال لم يعد موجوداً");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("حدث خطأ ما. يرجى المحاولة مرة أخرى.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "جارٍ الحذف..." : "حذف"}
    </button>
  );
}

معالجة الأخطاء الشاملة#

يمكنك إعداد معالج أخطاء شامل يلتقط جميع أخطاء tRPC غير المعالجة:

typescript
// في TRPCProvider الخاص بك
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // احتياطي شامل لأخطاء التحويل غير المعالجة
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // إعادة التوجيه لتسجيل الدخول
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

التحويلات والتحديثات المتفائلة#

التحويلات هي حيث يتكامل tRPC حقاً بشكل جيد مع TanStack Query. لنلقِ نظرة على نمط واقعي: زر إعجاب مع تحديثات متفائلة.

التحويل الأساسي#

typescript
// الخادم
const postRouter = router({
  toggleLike: protectedProcedure
    .input(z.object({ postId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });
 
      if (existing) {
        await ctx.db.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }
 
      await ctx.db.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
 
      return { liked: true };
    }),
});

التحديثات المتفائلة#

المستخدم يضغط "إعجاب." لا تريد الانتظار 200ms لاستجابة الخادم لتحديث الواجهة. التحديثات المتفائلة تحل هذا: حدّث الواجهة فوراً، ثم تراجع إذا رفض الخادم.

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
 
export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const utils = trpc.useUtils();
 
  const toggleLike = trpc.post.toggleLike.useMutation({
    onMutate: async ({ postId }) => {
      // إلغاء إعادة الجلب الصادرة حتى لا تكتب فوق تحديثنا المتفائل
      await utils.post.byId.cancel({ id: postId });
 
      // لقطة للقيمة السابقة
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // تحديث ذاكرة التخزين المؤقت بتفاؤل
      utils.post.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });
 
      // إعادة اللقطة للتراجع
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // التراجع للقيمة السابقة عند الخطأ
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // إعادة الجلب دائماً بعد الخطأ أو النجاح لضمان حالة الخادم
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

النمط هو نفسه دائماً:

  1. onMutate: إلغاء الاستعلامات، لقطة للبيانات الحالية، تطبيق التحديث المتفائل، إعادة اللقطة.
  2. onError: التراجع باستخدام اللقطة.
  3. onSettled: إبطال الاستعلام حتى يعيد الجلب من الخادم، بغض النظر عن النجاح أو الخطأ.

هذه الرقصة الثلاثية تضمن أن الواجهة دائماً متجاوبة ومتسقة في النهاية مع الخادم.

إبطال الاستعلامات المرتبطة#

بعد التحويل، غالباً ما تحتاج لتحديث البيانات المرتبطة. useUtils() الخاص بـ tRPC يجعل هذا مريحاً:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // إبطال قائمة تعليقات المقال
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // إبطال المقال نفسه (عدد التعليقات تغيّر)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // إبطال جميع قوائم المقالات (عدد التعليقات في عروض القائمة)
    utils.post.list.invalidate();
  },
});

التجميع والاشتراكات#

التجميع عبر HTTP#

افتراضياً، tRPC مع httpBatchLink يجمع طلبات متزامنة متعددة في استدعاء HTTP واحد. إذا عُرض مكوّن وأطلق ثلاثة استعلامات:

typescript
function Dashboard() {
  const user = trpc.user.me.useQuery();
  const posts = trpc.post.list.useQuery({ limit: 10 });
  const stats = trpc.stats.overview.useQuery();
 
  // ...
}

هذه الاستعلامات الثلاثة تُجمّع تلقائياً في طلب HTTP واحد: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

الخادم يعالج الثلاثة، ويُعيد الثلاث نتائج في استجابة واحدة، وTanStack Query يوزع النتائج على كل خطاف. لا حاجة لإعداد.

يمكنك تعطيل التجميع لاستدعاءات محددة إذا لزم الأمر:

typescript
// في مزوّدك، استخدم splitLink لتوجيه إجراءات محددة بشكل مختلف
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
 
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.path === "post.infiniteList",
        true: httpLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
          maxURLLength: 2048,
        }),
      }),
    ],
  })
);

اشتراكات WebSocket#

للميزات في الوقت الحقيقي، يدعم tRPC الاشتراكات عبر WebSockets. هذا يتطلب خادم WebSocket منفصلاً (Next.js لا يدعم WebSockets أصلياً في معالجات المسار).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// باعث أحداث في الذاكرة (استخدم Redis pub/sub في الإنتاج)
import { EventEmitter } from "events";
 
const eventEmitter = new EventEmitter();
 
export const notificationRouter = router({
  onNew: protectedProcedure.subscription(({ ctx }) => {
    return observable<Notification>((emit) => {
      const handler = (notification: Notification) => {
        if (notification.userId === ctx.user.id) {
          emit.next(notification);
        }
      };
 
      eventEmitter.on("notification", handler);
 
      return () => {
        eventEmitter.off("notification", handler);
      };
    });
  }),
 
  // تحويل يُحرّك إشعاراً
  markAsRead: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const notification = await ctx.db.notification.update({
        where: { id: input.id, userId: ctx.user.id },
        data: { readAt: new Date() },
      });
 
      return notification;
    }),
});

على العميل:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  trpc.notification.onNew.useSubscription(undefined, {
    onData: (notification) => {
      setNotifications((prev) => [notification, ...prev]);
      toast.info(notification.message);
    },
    onError: (error) => {
      console.error("خطأ في الاشتراك:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* واجهة قائمة الإشعارات */}
    </div>
  );
}

لنقل WebSocket، تحتاج عملية خادم مخصصة. إليك إعداداً أساسياً مع مكتبة ws:

typescript
// ws-server.ts (عملية منفصلة)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
 
const wss = new WebSocketServer({ port: 3001 });
 
const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext: createTRPCContext,
});
 
wss.on("connection", (ws) => {
  console.log(`تم فتح اتصال (${wss.clients.size} إجمالي)`);
  ws.once("close", () => {
    console.log(`تم إغلاق اتصال (${wss.clients.size} إجمالي)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("خادم WebSocket يستمع على ws://localhost:3001");

والعميل يحتاج wsLink للاشتراكات:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// استخدم splitLink لتوجيه الاشتراكات عبر WebSocket
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.type === "subscription",
        true: wsLink({ client: wsClient, transformer: superjson }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      }),
    ],
  })
);

رفع الملفات بأمان نوعي#

tRPC لا يتعامل مع رفع الملفات أصلياً. إنه بروتوكول JSON-RPC — البيانات الثنائية ليست في اختصاصه. لكن يمكنك بناء تدفق رفع آمن نوعياً بدمج tRPC مع عناوين URL الموقّعة مسبقاً.

النمط:

  1. العميل يطلب من tRPC عنوان URL موقّعاً مسبقاً للرفع.
  2. tRPC يتحقق من الطلب، ويفحص الصلاحيات، ويولّد العنوان.
  3. العميل يرفع مباشرة إلى S3 باستخدام العنوان الموقّع.
  4. العميل يُبلغ tRPC أن الرفع اكتمل.
typescript
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const uploadRouter = router({
  getPresignedUrl: protectedProcedure
    .input(
      z.object({
        filename: z.string().min(1).max(255),
        contentType: z.string().regex(/^(image|application)\//),
        size: z.number().max(10 * 1024 * 1024, "يجب أن يكون الملف أقل من 10MB"),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
 
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
 
      const presignedUrl = await getSignedUrl(s3, command, {
        expiresIn: 300, // 5 دقائق
      });
 
      // تخزين الرفع المعلق في قاعدة البيانات
      const upload = await ctx.db.upload.create({
        data: {
          key,
          userId: ctx.user.id,
          filename: input.filename,
          contentType: input.contentType,
          size: input.size,
          status: "PENDING",
        },
      });
 
      return {
        uploadId: upload.id,
        presignedUrl,
        key,
      };
    }),
 
  confirmUpload: protectedProcedure
    .input(z.object({ uploadId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const upload = await ctx.db.upload.findUnique({
        where: { id: input.uploadId, userId: ctx.user.id },
      });
 
      if (!upload) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      // التحقق من وجود الملف فعلاً في S3
      // (اختياري لكن موصى به)
 
      const confirmed = await ctx.db.upload.update({
        where: { id: upload.id },
        data: { status: "CONFIRMED" },
      });
 
      return {
        url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
      };
    }),
});

على العميل:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
  const confirmUpload = trpc.upload.confirmUpload.useMutation();
 
  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
 
      setUploading(true);
 
      try {
        // الخطوة 1: الحصول على عنوان URL موقّع من tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // الخطوة 2: الرفع مباشرة إلى S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("فشل الرفع");
        }
 
        // الخطوة 3: تأكيد الرفع عبر tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`تم رفع الملف: ${url}`);
      } catch (error) {
        toast.error("فشل الرفع. يرجى المحاولة مرة أخرى.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>جارٍ الرفع...</p>}
    </div>
  );
}

التدفق بأكمله آمن نوعياً. نوع استجابة العنوان الموقّع، ونوع معرّف الرفع، واستجابة التأكيد — كلها مستنتجة من تعريفات الخادم. إذا أضفت حقلاً جديداً لاستجابة العنوان الموقّع، العميل يعرف عنه فوراً.

الاستدعاءات من جانب الخادم ومكوّنات خادم React#

مع Next.js App Router، غالباً ما تريد جلب البيانات في مكوّنات الخادم. يدعم tRPC هذا من خلال المُستدعين من جانب الخادم:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// الاستخدام في مكوّن خادم
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
 
export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const ctx = await createTRPCContext({
    req: new Request("http://localhost"),
    resHeaders: new Headers(),
  });
  const caller = createCaller(ctx);
 
  const post = await caller.post.byId({ id: params.id });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* مكوّنات العميل للأجزاء التفاعلية */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

هذا يمنحك أفضل ما في العالمين: بيانات أولية معروضة من الخادم مع أمان نوعي كامل، وتفاعلية من جانب العميل للتحويلات والميزات في الوقت الحقيقي.

اختبار إجراءات tRPC#

الاختبار مباشر لأن الإجراءات مجرد دوال. لا تحتاج لتشغيل خادم HTTP.

typescript
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
 
describe("موجّه المستخدم", () => {
  it("يُعيد ملف المستخدم الحالي", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    prismaMock.user.findUnique.mockResolvedValue({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
 
    const result = await caller.user.me();
 
    expect(result).toEqual({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
  });
 
  it("يرمي UNAUTHORIZED للطلبات غير المصادق عليها", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("يتحقق من المدخل مع Zod", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    await expect(
      caller.user.updateProfile({
        name: "", // الحد الأدنى للطول 1
      })
    ).rejects.toThrow();
  });
});

لا محاكاة لطبقات HTTP، لا supertest، لا مطابقة مسارات. فقط استدعِ الدالة وأكّد النتيجة. هذه واحدة من مزايا tRPC غير المقدّرة: الاختبار بسيط بشكل تافه لأن طبقة النقل هي تفصيل تنفيذي.

متى لا تستخدم tRPC#

tRPC ليس حلاً شاملاً. إليك أين ينهار:

واجهات API العامة#

إذا كنت تبني API يستهلكه مطورون خارجيون، tRPC هو الخيار الخاطئ. المستهلكون الخارجيون ليس لديهم وصول لأنواع TypeScript الخاصة بك. يحتاجون عقداً موثّقاً ومستقراً — OpenAPI/Swagger لـ REST، أو مخطط GraphQL. أمان نوع tRPC يعمل فقط عندما يشترك العميل والخادم في نفس قاعدة كود TypeScript.

تطبيقات الهاتف (إلا إذا كنت تستخدم TypeScript)#

إذا كان تطبيق الهاتف مكتوباً بـ Swift، أو Kotlin، أو Dart، فإن tRPC لا يقدم شيئاً. الأنواع لا تعبر حدود اللغات. يمكنك نظرياً توليد مواصفة OpenAPI من مسارات tRPC باستخدام trpc-openapi، لكن عند تلك النقطة أنت تضيف التكلفة الإجرائية من جديد. فقط استخدم REST من البداية.

الخدمات المصغّرة#

tRPC يفترض قاعدة كود TypeScript واحدة. إذا كانت خلفيتك مقسمة عبر خدمات متعددة بلغات مختلفة، tRPC لا يمكنه المساعدة في الاتصال بين الخدمات. استخدم gRPC، أو REST، أو قوائم الرسائل لذلك.

فرق كبيرة بمستودعات واجهة أمامية/خلفية منفصلة#

إذا كانت الواجهة الأمامية والخلفية في مستودعات منفصلة بخطوط نشر منفصلة، تفقد ميزة tRPC الأساسية. مشاركة النوع تتطلب مستودعاً أحادياً أو حزمة مشتركة. يمكنك نشر نوع AppRouter كحزمة npm، لكن الآن لديك مشكلة إصدارات يتعامل معها REST + OpenAPI بشكل أكثر طبيعية.

عندما تحتاج دلالات REST#

إذا كنت تحتاج رؤوس تخزين HTTP المؤقت، أو التفاوض على المحتوى، أو ETags، أو ميزات REST محددة أخرى، فإن تجريد tRPC فوق HTTP سيقاومك. tRPC يتعامل مع HTTP كتفصيل نقل، ليس كميزة.

إطار القرار#

إليك كيف أقرر:

السيناريوالتوصية
تطبيق TypeScript fullstack في نفس المستودعtRPC — أقصى فائدة، أقل تكلفة
أداة داخلية / لوحة إدارةtRPC — سرعة التطوير هي الأولوية
API عام لمطورين خارجيينREST + OpenAPI — المستهلكون يحتاجون وثائق، ليس أنواعاً
عملاء هاتف + ويب (هاتف غير TS)REST أو GraphQL — يحتاجون عقوداً محايدة للغة
كثيف الوقت الحقيقي (محادثة، ألعاب)اشتراكات tRPC أو WebSockets خام حسب التعقيد
فرق واجهة أمامية/خلفية منفصلةGraphQL — المخطط هو العقد بين الفرق

نصائح عملية من الإنتاج#

بعض الأشياء التي تعلمتها من تشغيل tRPC في الإنتاج وليست في التوثيق:

حافظ على الموجّهات صغيرة. ملف موجّه واحد يجب ألا يتجاوز 200 سطر. قسّم حسب المجال: userRouter، postRouter، billingRouter. كل واحد في ملفه.

استخدم createCallerFactory للاستدعاءات من جانب الخادم. لا تلجأ لـ fetch عند استدعاء API الخاص بك من مكوّن خادم. مصنع المُستدعي يمنحك نفس الأمان النوعي بدون تكلفة HTTP.

لا تبالغ في تحسين التجميع. httpBatchLink الافتراضي كافٍ دائماً تقريباً. رأيت فرقاً تقضي أياماً في إعداد تكوينات splitLink لمكاسب هامشية. حلّل الأداء أولاً.

اضبط staleTime في QueryClient. الـ staleTime الافتراضي 0 يعني أن كل حدث تركيز يُحرّك إعادة جلب. اضبطه على شيء معقول (30 ثانية إلى 5 دقائق) بناءً على متطلبات حداثة البيانات.

استخدم superjson من اليوم الأول. إضافته لاحقاً يعني ترحيل كل عميل وخادم في وقت واحد. إنه إعداد سطر واحد يحميك من أخطاء سلسلة Date.

حدود الأخطاء صديقتك. غلّف أقسام الصفحة الكثيفة بـ tRPC في حدود أخطاء React. استعلام واحد فاشل يجب ألا يُسقط الصفحة بأكملها.

typescript
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="p-4 bg-red-50 rounded-lg">
      <p className="font-medium text-red-800">حدث خطأ ما</p>
      <pre className="text-sm text-red-600 mt-2">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
      >
        حاول مرة أخرى
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>لوحة التحكم</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

الخلاصة#

tRPC ليس بديلاً لـ REST أو GraphQL. إنه أداة مختلفة لحالة محددة: عندما تتحكم في كل من العميل والخادم، وكلاهما TypeScript، وتريد أقصر مسار ممكن من "غيّرت الخلفية" إلى "الواجهة الأمامية تعرف عن ذلك."

في تلك الحالة، لا شيء آخر يقترب. لا توليد كود، لا ملفات مخططات، لا انحراف. فقط TypeScript يفعل ما يجيده أفضل: التقاط الأخطاء قبل وصولها للإنتاج.

المقايضة واضحة: تتنازل عن التشغيل البيني على مستوى البروتوكول (لا عملاء غير TypeScript) مقابل سرعة تطوير وأمان في وقت التجميع يصعب تحقيقه بأي طريقة أخرى.

لمعظم تطبيقات TypeScript fullstack، هذه مقايضة تستحق القيام بها.

مقالات ذات صلة