সরাসরি পর্বে যান
পর্ব ৩৭ · ৯৩-এর মধ্যে · মডিউল ৫

Type-safe API — tRPC, ভাগ করা schema আর code generation

Type-Safe APIs — tRPC, Shared Schemas and Code Generation

Taught in Bangla, this lesson shows the gap between a frontend and backend joined only by JSON and three ways to close it: shared schema packages, code generation from OpenAPI or GraphQL, and tRPC type inference. It sets up a monorepo, builds tRPC routers and middleware, connects Express and React in a real run, renames a field to see errors, checks the bundle carries no server code, handles Dates with superjson, and says when REST and OpenAPI fit better.

এই পর্বে যা শিখবেন

  • Frontend-backend type-এর ফাঁক থেকে চলার সময় কী কী ভাঙে
  • npm workspaces দিয়ে বাজারঘরের monorepo সাজানো
  • tRPC-র router, procedure, input, query, mutation, context আর middleware
  • Express 5 adapter আর React client জোড়া লাগানো, TanStack Query সহ
  • Server-এ নাম বদলে client-এ আসল tsc error দেখা, আর bundle খুলে server কোড খোঁজা
  • REST ও OpenAPI, GraphQL আর tRPC-র মধ্যে কারণসহ বাছাই

১ · Frontend আর backend-এর মাঝের ফাঁক

একটি রেস্তোরাঁর কথা ভাবুন। রান্নাঘরে শেফ, সামনে ওয়েটার। ওয়েটারের হাতে ছাপানো মেনু। শেফ একদিন “ভুনা খিচুড়ি”-র নাম বদলে “ঢাকাই খিচুড়ি” করলেন, দামও বদলালেন। মেনু আগের মতোই রইল। ওয়েটার পুরোনো নাম লিখে অর্ডার আনলেন, আর রান্নাঘর বলল, “এই নামে কিছু নেই।” ভুলটি ধরা পড়ল খদ্দেরের সামনে। Frontend হলো ওয়েটার, backend হলো রান্নাঘর, আর API-র type হলো সেই মেনু।

পর্ব ০৬-এ দেখেছেন, TypeScript-এর type চলার সময় থাকে না। পর্ব ২১-এ একটি Zod schema দুই দিকে ব্যবহার করেছেন, আর পর্ব ২৭-এ server-এর দরজায় যাচাই বসিয়েছেন। কিন্তু একটি জায়গা এখনো খোলা। Server যা ফেরত পাঠায়, client সেটিকে কীভাবে চেনে? সাধারণত এভাবে:

type Product = { id: number; name: string; price: number }; // client-এর নিজের নকল

async function getProduct(id: number) {
  const res = await fetch(`/api/products/${id}`);
  return (await res.json()) as Product;                     // শুধু একটি প্রতিশ্রুতি
}

এখানে as Product কিছুই যাচাই করে না। এটি compiler-কে বলে, “আমাকে বিশ্বাস করুন।” Server বদলে গেলে প্রতিশ্রুতিটি মিথ্যা হয়ে যায়, আর compiler চুপ থাকে। এই অবস্থাকে বলে type drift: দুই পাশের ধারণা ধীরে ধীরে আলাদা হয়ে যাওয়া। ফলাফল এরকম হয়:

Server-এ বদলtsc কী বলেচলার সময় কী ঘটে
price হলো priceTakaকিছু নাp.price হয় undefined; p.price * 2 হয় NaN
price সংখ্যা থেকে string "450"কিছু নাp.price + 20 হয় "45020", মোট দামে ভুল
createdAt একটি Dateকিছু নাJSON-এ সেটি string, তাই getFullYear() crash করে
নতুন বাধ্যতামূলক input qtyকিছু নাServer 400 ফেরত দেয়, ব্যবহারকারী দেখেন “কিছু একটা ভুল হয়েছে”

লক্ষ করুন, ভুলগুলো কোনোটিই বড় নয়। কিন্তু ধরা পড়ে দেরিতে। সবচেয়ে সস্তা জায়গা হলো compile-এর সময়, সবচেয়ে দামি জায়গা হলো ব্যবহারকারীর স্ক্রিন। এই পর্বের লক্ষ্য end-to-end type safety: একটি বদল করলে অন্য প্রান্তের ভাঙা লাইনটি compiler নিজেই দেখিয়ে দেবে।

২ · ফাঁক বন্ধের তিন পথ

রেস্তোরাঁয় ফিরে যাই। মেনুর সমস্যা তিনভাবে মেটানো যায়। শেফ আর ওয়েটার একটি অভিন্ন খাতা দেখে কাজ করতে পারেন। অথবা শেফের মূল তালিকা থেকে ছাপাখানা প্রতিবার নতুন মেনু ছাপতে পারে। অথবা দুজন একই দেয়ালের বোর্ড দেখতে পারেন, যেখানে শেফ লিখলেই ওয়েটার সঙ্গে সঙ্গে দেখেন। সফটওয়্যারের তিন পথও ঠিক এমন।

১. ভাগ করা schema package

একটি package-এ Zod schema থাকে। Server সেটি দিয়ে যাচাই করে, client সেটি থেকে type নেয়। সবচেয়ে সরল পথ। তবে server-এর উত্তর ওই schema মানছে কি না, সেটি আপনাকেই নিশ্চিত করতে হয়।

অভিন্ন খাতা

২. Code generation

একটি চুক্তি ফাইল (OpenAPI বা GraphQL schema) থেকে টুল type লেখে। ভাষা-নিরপেক্ষ, তাই Kotlin বা Swift client-ও পায়। দাম হলো একটি বাড়তি build ধাপ।

ছাপাখানা

৩. Inference (tRPC)

কোনো আলাদা চুক্তি ফাইল নেই। Client সরাসরি server-এর typeof router পড়ে। কোনো ধাপ নেই, বদল সঙ্গে সঙ্গে দেখা যায়। দাম হলো দুই প্রান্তে TypeScript আর শক্ত বাঁধন।

অভিন্ন বোর্ড

দ্বিতীয় পথটি একবার চালিয়ে দেখা যাক। চুক্তি লেখা আর openapi-typescript-এর বিস্তার পর্ব ৩৫-এর বিষয়। এখানে শুধু দেখব, নাম বদলালে কখন ধরা পড়ে। Spec-এ Product-এর price আছে, আর client লিখেছে data.price. এবার spec-এ নাম বদলে priceTaka করা হলো।

সত্যিকারের run · openapi-typescript 7.13.0, openapi-fetch 0.17.0, TypeScript 5.9.3 · ~/bazarghar-codegen

$ npx tsc -p .                               # spec বদলানোর পরে, generate করার আগে
$ echo $?
0
$ npx openapi-typescript openapi.yaml -o schema.ts
✨ openapi-typescript 7.13.0
🚀 openapi.yaml → schema.ts [19.3ms]
$ npx tsc -p .
client.ts(9,32): error TS2339: Property 'price' does not exist on type '{ id: number; name: string; priceTaka: number; }'.

দেখুন, প্রথম tsc কিছুই ধরেনি, কারণ পুরোনো schema.ts তখনো রয়ে গেছে। Code generation-এর ঝুঁকি ঠিক এখানে: generate করতে ভুলে গেলে সুরক্ষা নেই। তাই CI-তে একটি ধাপ রাখা হয়, যা আবার generate করে দেখে ফাইলটি বদলাল কি না। GraphQL-এ একই কাজ করে GraphQL Code Generator-এর মতো টুল (এখানে চালানো হয়নি; GraphQL-এর ভিত্তি পর্ব ৩৬-এ)।

একটি সত্যিকারের ফাঁদ: লেখার সময়ে openapi-typescript 7.13.0-র peer dependency হলো typescript ^5.x. TypeScript 6.0 দিয়ে ইনস্টল করতে গিয়ে আমাদের run-এ ERESOLVE error এসেছে, তাই এই ছোট project-এ 5.9.3 রাখা হয়েছে। একই রকম সীমা আছে typescript-eslint-এ: 8.72-র জন্য typescript লাগে 6.1-এর নিচে, যেমন ~6.0. বাজারঘরের tRPC project-এ এই দুটির কোনোটি নেই, তাই সেখানে 7.0.2 চলেছে। টুল বসানোর আগে তার peer dependency দেখে নিন।

৩ · Monorepo — এক ছাদের নিচে web, api আর shared

পথ যেটিই হোক, type ভাগ করতে হলে কোড এক জায়গায় থাকলে কাজ সহজ হয়। Monorepo মানে একটি repository-র ভেতরে কয়েকটি package. npm-এর workspace সুবিধা এদের পরস্পরের সঙ্গে জুড়ে দেয়, npm-এ প্রকাশ না করেই। পর্ব ০৫-এ ধারণাটি এসেছে, এবার সত্যিই বানাই।

bazarghar/
├── package.json          "workspaces": ["packages/*", "apps/*"]
├── tsconfig.base.json    strict, verbatimModuleSyntax
├── packages/
│   └── shared/           @bazarghar/shared  (Zod schema)
└── apps/
    ├── api/              @bazarghar/api     (Express + tRPC)
    └── web/              @bazarghar/web     (Vite + React)
import * as z from "zod";

export const productId = z.object({ id: z.number().int().positive() });

export const createOrderInput = z.object({
  productId: z.number().int().positive(),
  qty: z.number().int().min(1).max(10),
});

export type CreateOrderInput = z.infer<typeof createOrderInput>;

Package-এর package.json-এ "exports": { ".": "./src/index.ts" } লেখা। অর্থাৎ অন্য package সরাসরি TypeScript উৎস ফাইল পড়ে, কারণ tsx আর Vite দুটোই সেটি চালাতে পারে। Package বাইরে প্রকাশ করতে চাইলে আগে build করতে হতো। ইনস্টলের পরে যা হলো:

সত্যিকারের run · npm 11.12.1, Node.js 24.15 · ~/bazarghar · লম্বা অংশ ছাঁটা

$ npm install -w @bazarghar/api @trpc/server @trpc/client express superjson @bazarghar/shared@0.0.0
added 69 packages in 2s

$ npm ls --depth=0 --workspaces
+-- @bazarghar/api@0.0.0 -> ./apps/api
| +-- @bazarghar/shared@0.0.0 deduped -> ./packages/shared
| +-- @trpc/client@11.22.1
| +-- @trpc/server@11.22.1
| +-- express@5.3.0
| `-- superjson@2.2.6
+-- @bazarghar/shared@0.0.0 -> ./packages/shared
| `-- zod@4.6.7
`-- @bazarghar/web@0.0.0 -> ./apps/web
  +-- @bazarghar/api@0.0.0 deduped -> ./apps/api
  +-- @tanstack/react-query@5.105.1
  +-- @trpc/tanstack-react-query@11.22.1
  +-- react@19.3.1
  `-- vite@8.3.5
…

তীরচিহ্ন -> বলছে, এগুলো কপি নয়। node_modules/@bazarghar/shared আসলে packages/shared ফোল্ডারের একটি symlink. তাই shared-এ একটি লাইন বদলালে api আর web সঙ্গে সঙ্গে দেখে, কোনো publish লাগে না। pnpm-এ একই কাজের জন্য থাকে pnpm-workspace.yaml আর "workspace:*"; এই পর্বের run npm দিয়ে হয়েছে।

কোন জিনিস কোন পথে যায়

Types flow at build time and vanish; only JSON crosses at runtime

Type build-এর সময় যায় আর মুছে যায়, চলার সময় শুধু JSON চলে Build-এর সময়: monorepo packages/shared Zod schema apps/web React, tRPC client apps/api Express, router browser bundle server কোড নেই Node process router, secret import type AppRouter build চালানো HTTP + JSON (চলার সময়) চলার সময়: দুই program কোড সত্যিই যায় শুধু type, build-এ মুছে যায়
ছবি ৩৭.১ — web থেকে shared-এর বিন্দু-তীরটি ঐচ্ছিক: web চাইলে একই Zod schema form-এ ব্যবহার করতে পারে, তখন Zod bundle-এ ঢোকে।

৪ · tRPC-র গঠন — router থেকে middleware

tRPC-র পুরো নাম TypeScript Remote Procedure Call. RPC মানে অন্য যন্ত্রের একটি function এমনভাবে ডাকা, যেন সেটি নিজের কোডের function. REST-এ আপনি ভাবেন resource আর URL নিয়ে। tRPC-তে ভাবেন function নিয়ে: product.byId, order.create. লেখার সময়ে npm-এর latest হলো 11.22.1; major version 11 এসেছে ২০২৫ সালের মার্চে।

tRPC-র প্রথম প্রমাণ-ধারণা (proof of concept) লিখেছিলেন Zod-এর স্রষ্টা Colin McDonnell. Alex Johansson (KATT) সেটি হাতে নিয়ে প্রকল্পে রূপ দেন। tRPC-র ব্লগে “Introducing tRPC” এসেছে ২০২১ সালের মে মাসে, v10 ২০২২ সালের নভেম্বরে, v11 ২০২৫ সালের মার্চে। এটি TypeScript-এর ২০১৮ সালের 2.8 version-এ আসা conditional type-এর ওপর দাঁড়িয়ে আছে, যা ছাড়া এত গভীর inference সম্ভব হতো না।

প্রথমে ভিত্তি। এই ফাইল একবার লেখা হয়, পুরো backend এটি ব্যবহার করে।

import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import superjson from "superjson";

type User = { id: number; name: string };

// শুধু demo: আসল অ্যাপে এখানে session বা JWT পড়া হয়
const tokens = new Map<string, User>([["example-token", { id: 7, name: "Rima" }]]);

export function createContext({ req }: CreateExpressContextOptions) {
  const token = req.headers.authorization?.replace("Bearer ", "");
  return { user: token ? (tokens.get(token) ?? null) : null };
}
type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create({ transformer: superjson });

export const router = t.router;

const timing = t.middleware(async ({ path, type, next }) => {
  const start = performance.now();
  const result = await next();
  const ms = (performance.now() - start).toFixed(1);
  console.log(`${type} ${path} ok=${result.ok} ${ms}ms`);
  return result;
});

export const publicProcedure = t.procedure.use(timing);

export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "login required" });
  return next({ ctx: { user: ctx.user } }); // এখান থেকে user আর null নয়
});

এবার আসল API. লক্ষ করুন costPrice (কেনা দাম)। এটি ব্যবসার গোপন তথ্য, তাই toPublic সেটি বাদ দিয়ে উত্তর সাজায়।

import { TRPCError } from "@trpc/server";
import { createOrderInput, productId } from "@bazarghar/shared";
import { publicProcedure, protectedProcedure, router } from "./trpc.ts";

// server-এর জিনিস: browser bundle-এ কখনো যাওয়া চলবে না
export const DB_URL = "postgres://app:example-secret@127.0.0.1/bazarghar";

const products = [
  { id: 1, name: "মিনিকেট চাল ৫ কেজি", price: 450, costPrice: 400, stock: 12 },
  { id: 2, name: "সয়াবিন তেল ১ লিটার", price: 180, costPrice: 160, stock: 3 },
];
type Row = (typeof products)[number];
const toPublic = ({ id, name, price, stock }: Row) => ({ id, name, price, stock });

export const appRouter = router({
  product: router({
    list: publicProcedure.query(() => products.map(toPublic)),
    byId: publicProcedure.input(productId).query(({ input }) => {
      const row = products.find((p) => p.id === input.id);
      if (!row) throw new TRPCError({ code: "NOT_FOUND", message: `product ${input.id} not found` });
      return toPublic(row);
    }),
  }),
  order: router({
    create: protectedProcedure.input(createOrderInput).mutation(({ input, ctx }) => {
      const row = products.find((p) => p.id === input.productId);
      if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "no such product" });
      if (row.stock < input.qty)
        throw new TRPCError({ code: "CONFLICT", message: `only ${row.stock} left` });
      row.stock -= input.qty;
      return { orderId: 501, buyer: ctx.user.name, total: row.price * input.qty, createdAt: new Date() };
    }),
  }),
});

export type AppRouter = typeof appRouter;
অংশকাজREST-এ কাছাকাছি কী
routerProcedure-গুলোকে গাছের মতো সাজায়express.Router()
procedureClient যে function ডাকতে পারেএকটি route handler
.input(schema)চলার সময় যাচাই, আর type-ও সেখান থেকে আসেZod validation middleware
.query()পড়ার কাজ; আমাদের run-এ HTTP GET হয়েছেGET
.mutation()লেখার কাজ; আমাদের run-এ HTTP POST হয়েছেPOST, PATCH, DELETE
context (ctx)প্রতি request-এ তৈরি থলি: user, db, request idreq.user, res.locals
middleware (.use)Procedure-এর আগে-পরে চলে; ctx-এর type-ও বদলাতে পারেExpress middleware

শেষ সারিটি খেয়াল করুন। Express-এ req.user আছে কি না, type বলতে পারে না। এখানে protectedProcedure পার হলে ctx.user-এর type User | null থেকে User হয়ে যায়। তাই ctx.user.name লিখতে কোনো ! লাগেনি। এটি পর্ব ২৬-এর পাইপলাইন, শুধু type-সহ। Login-এর আসল ব্যবস্থা আসবে পর্ব ৪৫ আর ৪৬-এ; এখানকার token তালিকা শুধু শেখার জন্য।

Inference ফিল্টার নয়। Procedure যা return করে, তা-ই client-এ যায়। আমরা পরীক্ষায় একটি procedure-এ সরাসরি products ফেরত দিয়েছিলাম, উত্তরে "costPrice":400 চলে এসেছে। toPublic-এর মতো হাতে বাছাই করুন, অথবা .output(schema) ব্যবহার করুন। একই পরীক্ষায় .output()-এর Zod object অজানা field ফেলে দিয়েছে, শুধু id, name আর price গেছে। অনুমতির তত্ত্ব আছে API security-তে।

৫ · Express আর React-এ জোড়া — সত্যিকারের run

Router কোনো নির্দিষ্ট server-এ বাঁধা নয়। Adapter তাকে Express-এর middleware বানিয়ে দেয়। তাই পুরোনো Express অ্যাপের ভেতরেও /trpc পথে tRPC বসানো যায়, পাশের REST route অক্ষত থাকে।

import express from "express";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter } from "./router.ts";
import { createContext } from "./trpc.ts";

const app = express();
app.use("/trpc", createExpressMiddleware({
  router: appRouter,
  createContext,
  onError({ path, error }) {
    if (error.code === "INTERNAL_SERVER_ERROR") console.error(path, error);
  },
}));
app.listen(4537, "127.0.0.1", () => console.log("api on http://127.0.0.1:4537/trpc"));

এবার client. tRPC-র বর্তমান docs নতুন project-এর জন্য @trpc/tanstack-react-query সুপারিশ করে। এটি আলাদা hook বানায় না, বরং পর্ব ১৯-এর চেনা useQuery আর useMutation-এর জন্য option তৈরি করে।

import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@bazarghar/api/router"; // শুধু type, bundle-এ যাবে না

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
const queryClient = new QueryClient();

function App() {
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [httpBatchLink({
        url: "/trpc",
        transformer: superjson,
        headers: () => ({ authorization: "Bearer example-token" }),
      })],
    }),
  );
  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        <Shop />
      </TRPCProvider>
    </QueryClientProvider>
  );
}
export function Shop() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const list = useQuery(trpc.product.list.queryOptions());
  const one = useQuery(trpc.product.byId.queryOptions({ id: 2 }));
  const buy = useMutation(trpc.order.create.mutationOptions({
    onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.product.list.queryKey() }),
  }));
  // … list.data.map((p) => p.name, p.price) দিয়ে তালিকা,
  // আর বোতামে buy.mutate({ productId: p.id, qty: 1 })
}

Vite-এর build Edge browser-এ Playwright দিয়ে খুলে একটি “কিনুন” বোতাম চাপা হলো, আর সব network অনুরোধ ধরা হলো। পাশাপাশি একটি ছোট Node client দিয়ে ভুলের পথগুলো পরখ করা হলো।

সত্যিকারের run · tRPC 11.22.1, Express 5.3.0, TanStack Query 5.105.1, React 19.3.1, Vite 8.3.5, Zod 4.6.7 · Edge browser · ~/bazarghar

# browser-এর network (URL-decode করা, পড়ার জন্য ভাঙা)
GET /trpc/product.list,product.byId
    ?batch=1&input={"0":{"json":null,"meta":{"values":["undefined"],"v":1}},"1":{"json":{"id":2}}}
200 [{"result":{"data":{"json":[{"id":1,"name":"মিনিকেট চাল ৫ কেজি","price":450,"stock":9},…]}}},
     {"result":{"data":{"json":{"id":2,"name":"সয়াবিন তেল ১ লিটার","price":180,"stock":3}}}}]

# "কিনুন" চাপার পরে
POST /trpc/order.create?batch=1
GET  /trpc/product.list?batch=1&input=…

# পাতায় যা দেখা গেল
মিনিকেট চাল ৫ কেজি: 450 টাকা, স্টকে 8
সয়াবিন তেল ১ লিটার: 180 টাকা, স্টকে 3
অর্ডার 501: মোট 450, বছর 2026

# server-এর log, timing middleware থেকে
query product.list ok=true 0.3ms
query product.byId ok=true 0.4ms
mutation order.create ok=true 0.2ms
query product.list ok=true 0.0ms

তিনটি জিনিস খেয়াল করুন। প্রথমত, দুটি আলাদা useQuery একটিমাত্র HTTP অনুরোধে গেছে। এটি httpBatchLink-এর কাজ: কাছাকাছি সময়ের ডাকগুলো এক অনুরোধে জুড়ে দেওয়া, উত্তর আসে একই ক্রমের array-তে। দ্বিতীয়ত, mutation-এর পরে invalidateQueries তালিকা আবার এনেছে, তাই স্টক কমে পাতায় 8 হয়েছে। তৃতীয়ত, URL-এ input থাকায় অনেক বড় batch URL-এর সীমায় পড়তে পারে; এজন্য httpBatchLink-এ maxURLLength option আছে।

  1. ComponentbyId({id:2})tsc যাচাই করে
  2. Proxy"product.byId"path জমায়
  3. Batch linkGET /trpc/…HTTP অনুরোধ
  4. Adapterctx, Zodচলার সময় যাচাই
  5. ResolvertoPublic(row)উত্তর তৈরি
  6. Cache, UIone.datarender
Shop.tsxচলার আগে, compile-এর সময়
trpc.product.byId.queryOptions({ id: 2 })
// { id: "2" } লিখলে tsc এখানেই থামাত
// input-এর type এসেছে server-এর Zod schema থেকে
Client Proxyচলার সময়, browser-এ
trpc.product.byId  →  path: ["product", "byId"]
input: { id: 2 }
// browser-এ কোনো procedure তালিকা নেই
Networkএকই সময়ের দুটি ডাক এক অনুরোধে
GET /trpc/product.list,product.byId
    ?batch=1
    &input={"0":{…},
      "1":{"json":{"id":2}}}
authorization: Bearer example-token
Express adapterNode process
createContext → { user: { id: 7, name: "Rima" } }
timing middleware শুরু
productId.parse({ id: 2 }) → ঠিক আছে
// { id: -5 } হলে এখানেই BAD_REQUEST 400
Resolverউত্তর, superjson হয়ে
{"result":{"data":{"json":{"id":2,
 "name":"সয়াবিন তেল ১ লিটার","price":180,"stock":3}}}}
costPrice নেই, toPublic বাদ দিয়েছে
query product.byId ok=true 0.4ms
Browserপাতায়
one.data?.name  →  সয়াবিন তেল ১ লিটার
one.data-এর type: { id, name, price, stock } | undefined
  1. Compile-এর সময়। Component byId ডাকছে { id: 2 } দিয়ে। tsc AppRouter type পড়ে মিলিয়ে নেয়। ভুল থাকলে কোনো অনুরোধই তৈরি হতো না। এই ধাপে network নেই।
  2. Proxy path জমায়। trpc.product.byId কোনো আসল object নয়। Proxy প্রতিটি . ধরে নাম জমায়: product, তারপর byId. Browser জানেই না server-এ কী কী procedure আছে।
  3. Batch link অনুরোধ পাঠায়। Query বলে GET. একই সময়ের product.list আর product.byId কমা দিয়ে এক path-এ জুড়ে গেছে, input-এ 0 আর 1 হলো তাদের ক্রম।
  4. দরজায় যাচাই। Adapter createContext চালায়, middleware চলে, তারপর Zod ইনপুট যাচাই করে। Browser-এর পাঠানো জিনিস কখনো বিশ্বাস করা হয় না। Type মুছে গেছে, এই দেয়ালটিই চলার সময়ের সুরক্ষা।
  5. Resolver উত্তর বানায়। toPublic গোপন field বাদ দেয়। superjson উত্তরকে json ঘরে রাখে। এখানে Date নেই, তাই meta ঘরটি নেই। Timing middleware log লেখে: ok=true 0.4ms.
  6. Cache আর render. TanStack Query উত্তর cache-এ রাখে, React পাতায় বসায়। one.data-র type কেউ হাতে লেখেনি: resolver-এর return থেকে এসেছে। ক্রমটি মনে রাখুন: type আগে, network মাঝে, type আবার শেষে।

৬ · Type কীভাবে সীমানা পার হয় — নিজে বানিয়ে দেখুন

আসল tRPC পাতায় চালানো যায় না। কিন্তু তার মূল কৌশল মাত্র কয়েক লাইনের TypeScript. নিচের ব্লকগুলো শেখার জন্য বানানো মডেল, আসল tRPC নয়। প্রথম ব্লকে একটি router-এর মতো object আছে, আর server-এ নাম বদলে গেছে। চালিয়ে দেখুন।

// server-এর দিক: router-এর মতো একটি object (শেখার মডেল)
const router = {
  product: {
    byId: async (input: { id: number }) => ({
      id: input.id,
      name: "oil 1L",
      priceTaka: 180,            // আগে এর নাম ছিল price
    }),
  },
};
type AppRouter = typeof router;  // client পর্যন্ত যায় শুধু type

// client-এর দিক: কোড নয়, শুধু AppRouter জানে
type ById = AppRouter["product"]["byId"];
type Product = Awaited<ReturnType<ById>>;

function label(p: Product): string {
  return `${p.name}: ${p.price} taka`;
}

function ask(input: Parameters<ById>[0]) {
  return input.id;
}
ask({ id: "2" });

router.product.byId({ id: 2 }).then((p) => console.log(label(p)));

Compiler দুটি বার্তা দিয়েছে: Property 'price' does not exist on type '{ id: number; name: string; priceTaka: number; }' আর Type 'string' is not assignable to type 'number'. কেউ Product type হাতে লেখেনি। typeof router কোডের আকৃতিকে type বানিয়েছে। ReturnType function-এর ফেরত type নিয়েছে, যা একটি Promise, আর Awaited তার মোড়ক খুলেছে। Parameters দিয়েছে input-এর type. বাকি কাজ ওই তিনটি ছোট helper-এর। tRPC-র inferRouterOutputs<AppRouter> একই ধারণার পরিণত রূপ।

এবার client বানাই। সমস্যা হলো, type চলার সময় থাকে না। তাহলে api.product.byId(…) চলবে কীভাবে? উত্তর দুই ভাগে। Type-এর ভাগে একটি mapped type router-এর আকৃতি নকল করে। চলার সময়ের ভাগে একটি JavaScript Proxy যেকোনো property পড়া ধরে ফেলে, আর শেষে ডাক পড়লে path-সহ অনুরোধ পাঠায়।

// ছোট typed RPC client: tRPC-র ধারণার শেখার মডেল, আসল tRPC নয়
const router = {
  product: { byId: async (input: { id: number }) => ({ id: input.id, name: "oil 1L", price: 180 }) },
  order: {
    create: async (input: { productId: number; qty: number }) => ({ orderId: 501, total: 180 * input.qty }),
  },
};
type AppRouter = typeof router;

// function হলে যেমন আছে তেমনই, object হলে আবার ভেতরে
type Client<R> = { [K in keyof R]: R[K] extends (i: any) => any ? R[K] : Client<R[K]> };
type Send = (path: string, input: unknown) => Promise<unknown>;

function createClient<R>(send: Send, path: string[] = []): Client<R> {
  return new Proxy(() => {}, {
    get: (_t, key) => createClient(send, [...path, String(key)]),
    apply: (_t, _self, args) => send(path.join("."), args[0]),
  }) as unknown as Client<R>;
}

// "network": client জানে শুধু path আর JSON
const send: Send = async (path, input) => {
  console.log("send", path, JSON.stringify(input));
  const [group, name] = path.split(".") as ["order", "create"];
  return router[group][name](input as { productId: number; qty: number });
};

const api = createClient<AppRouter>(send);

async function main() {
  const p = await api.product.byId({ id: 2 });   // p-র পুরো type জানা
  const o = await api.order.create({ productId: p.id, qty: 3 });
  console.log(p.name, p.price + 20, o.total.toFixed(2));
}
main();

আউটপুট:

send product.byId {"id":2}
send order.create {"productId":2,"qty":3}
oil 1L 200 540.00

p.price + 20 হয়েছে 200, আর o.total.toFixed(2) compile হয়েছে, কারণ compiler জানে দুটোই সংখ্যা। Client<R>-এর extends … ? অংশটি conditional type. as unknown as লাগে, কারণ Proxy-র আসল আকৃতি compiler দেখতে পায় না; এই একটি জায়গায় আমরা নিজ দায়িত্বে প্রতিশ্রুতি দিচ্ছি। আসল tRPC-র ভেতরেও এমন recursive proxy আছে, যা দিয়ে @trpc/client path জমায়।

এবার নকল network বাদ দিই। নিচের ব্লকে Node-এর একটি আসল HTTP server চলে, আর Proxy client fetch দিয়ে তাকে ডাকে। Type নেই বলে ভুল নাম এখানে compile-এর সময় ধরা পড়ে না। তফাতটি দেখুন।

const http = require("node:http");
const procedures = {
  "product.byId": ({ id }) => ({ id, name: "oil 1L", price: 180 }),
  "product.stock": ({ id }) => ({ id, stock: 3 }),
};
const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://x");
  const fn = procedures[url.pathname.slice(1)];
  res.setHeader("content-type", "application/json");
  if (!fn) { res.statusCode = 404; return res.end('{"error":"NOT_FOUND"}'); }
  console.log("server got", req.method, req.url);
  res.end(JSON.stringify(fn(JSON.parse(url.searchParams.get("input")))));
});

// client: procedure-এর তালিকা কোথাও নেই, Proxy শুধু path জমায়
function client(base, path = []) {
  return new Proxy(() => {}, {
    get: (_t, key) => client(base, [...path, key]),
    apply: async (_t, _self, [input]) => {
      const q = encodeURIComponent(JSON.stringify(input));
      const res = await fetch(`${base}/${path.join(".")}?input=${q}`);
      if (!res.ok) throw new Error(`${res.status} ${path.join(".")}`);
      return res.json();
    },
  });
}

server.listen(0, "127.0.0.1", async () => {
  const api = client(`http://127.0.0.1:${server.address().port}`);
  console.log(await api.product.byId({ id: 2 }));
  console.log(await api.product.stock({ id: 2 }));
  await api.product.price({ id: 2 }).catch((e) => console.log("caught", e.message));
  server.close();
});

আউটপুট:

server got GET /product.byId?input=%7B%22id%22%3A2%7D
{ id: 2, name: 'oil 1L', price: 180 }
server got GET /product.stock?input=%7B%22id%22%3A2%7D
{ id: 2, stock: 3 }
caught 404 product.price

Client-এ product.stock-এর নাম কোথাও লেখা নেই, তবু ডাক চলেছে। product.price নামে কিছু নেই, সেটি ধরা পড়েছে চলার সময়, 404 হয়ে। এই ব্লকের সঙ্গে আগের ব্লক মেলালেই tRPC-র পুরো ছবি পাবেন: Proxy দেয় চলার ক্ষমতা, আর typeof router দেয় compile-এর সময়ের সুরক্ষা। দ্বিতীয়টি মুছে গেলেও প্রথমটি চলে। তাই bundle-এ server কোড লাগে না।

৭ · নাম বদলালে কী হয় — আর bundle-এ server কোড আছে কি

আসল project-এ ফিরি। ধরুন দাম টাকায় না পয়সায়, এই নিয়ে বিভ্রান্তি কাটাতে price হবে priceTaka. নিচের ধাপগুলো বাজারঘরে সত্যিই চালানো হয়েছে।

apps/api/src/router.tsএকটি শব্দ বদল
const toPublic = ({ id, name, price, stock }: Row) =>
  ({ id, name, priceTaka: price, stock });
Terminalserver নিজে ঠিক আছে
$ npx tsc -p apps/api --noEmit
$ echo $?
0
Terminalclient ভেঙেছে
$ npx tsc -p apps/web --noEmit
apps/web/src/Shop.tsx(21,26): error TS2339:
Property 'price' does not exist on type
'{ id: number; name: string; priceTaka: number; stock: number; }'.
$ echo $?
1
TerminalVite type দেখে না
$ npx vite build
dist/assets/index-BgFbkS4w.js  293.78 kB │ gzip: 90.77 kB
✓ built in 104ms

পাতায়, Edge browser-এ:
মিনিকেট চাল ৫ কেজি: টাকা, স্টকে 12
দাম নেই, কোনো error-ও নেই
Terminalসঠিক build কমান্ড
$ npm run build     # typecheck && vite build
> tsc -p apps/api --noEmit && tsc -p apps/web --noEmit
apps/web/src/Shop.tsx(21,26): error TS2339: …
exit 1              # vite build চলেইনি
  1. বদল। router.ts-এ শুধু একটি field-এর নাম বদলাল। Client-এর কোডে কেউ হাত দেননি। পুরোনো পথে এখান থেকেই type drift শুরু হতো।
  2. Server সুখী। Server-এর দিক থেকে কোনো ভুল নেই, নতুন নাম বৈধ। tsc -p apps/api-এর exit code 0.
  3. Client ধরা পড়ল। Shop.tsx-এর ২১ নম্বর লাইন, ২৬ নম্বর কলাম। Compiler ফাইল, লাইন আর নতুন আকৃতি তিনটিই বলে দিল। কোনো request পাঠাতে হয়নি। এটিই পুরো পর্বের কেন্দ্রের মুহূর্ত।
  4. ফাঁদ: Vite চুপ। Vite-এর build type মুছে কোড বানায়, type মেলায় না। তাই একই ভাঙা কোডে ✓ built এল। সেই build চালিয়ে দেখা গেল, পাতায় দামের জায়গা ফাঁকা। React undefined কিছুই আঁকে না, তাই কোনো error-ও নেই। ঠিক এই ভুলটিই আমরা ঠেকাতে চেয়েছিলাম।
  5. সমাধান: tsc build-এর অংশ। মূল package.json-এ "build": "npm run typecheck && npm run build -w @bazarghar/web". এখন tsc ব্যর্থ হলে &&-এর পরের অংশ চলে না, exit code 1. CI-তেও এই কমান্ডই চলবে (পর্ব ৮১)। Type-safety কাজ করে তখনই, যখন কেউ সত্যিই type মেলায়।

একই সুরক্ষা উল্টো দিকেও আছে। Client-এ buy.mutate({ productId: p.id, quantity: 1 }) লিখলে আসল run-এ এল: error TS2353: Object literal may only specify known properties, and 'quantity' does not exist in type '{ productId: number; qty: number; }'.

Client কি server কোড পেল? Bundle খুলে দেখি

“Client server-কে import করছে” শুনলে ভয় লাগা স্বাভাবিক। router.ts-এ DB_URL আর costPrice আছে। উত্তর আছে import type-এ। এটি TypeScript-কে বলে, এই import শুধু type-এর জন্য, build-এ পুরো লাইনটি মুছে ফেলুন। কথায় বিশ্বাস না করে আমরা তৈরি JavaScript ফাইলে গোপন শব্দগুলো খুঁজলাম, আর ইচ্ছা করে ভুল import-ও করলাম।

সত্যিকারের run · Vite 8.3.5 · আকার একটি স্থানীয় build-এর, আপনার project-এ ভিন্ন হবে

$ cat dist/assets/*.js | grep -c "example-secret"
0
$ cat dist/assets/*.js | grep -c "costPrice"
0

# ভুল import, আর কোডে DB_URL-এর ব্যবহার:
# import { appRouter, DB_URL } from "@bazarghar/api/router";
$ npx tsc -p apps/web --noEmit ; echo $?
0                                        # tsc-এর কোনো আপত্তি নেই
$ npx vite build
✓ 195 modules transformed.               # আগে ছিল 97
dist/assets/index-DWIhlz4B.js  373.30 kB │ gzip: 114.18 kB
$ cat dist/assets/*.js | grep -c "example-secret"
1
$ cat dist/assets/*.js | grep -c "costPrice"
1
apps/web-এর importআকারgzipগোপন শব্দ
import type { AppRouter }293.78 kB90.77 kBনেই
ওপরের সঙ্গে Zod schema ব্যবহার (form-এর জন্য)361.08 kB110.11 kBনেই
import { appRouter, DB_URL }373.30 kB114.18 kBআছে

হিসাবটি করি। Zod নিলে বাড়ে ৩৬১.০৮ − ২৯৩.৭৮ = ৬৭.৩০ kB, আর gzip-এ ১১০.১১ − ৯০.৭৭ = ১৯.৩৪ kB, অর্থাৎ প্রায় ২১.৩%. ধরুন ব্যবহারকারীর গতি ১ Mbps (অনুমান)। তাহলে বাড়তি ১৯.৩৪ × ৮ ÷ ১০০০ ≈ ০.১৫ সেকেন্ড শুধু নামাতে। এটি খারাপ নয়, বিনিময়ে client-এ তাৎক্ষণিক যাচাই পাওয়া যায়। কিন্তু সিদ্ধান্তটি সচেতন হওয়া দরকার। Bundle নিয়ে বিস্তারিত পর্ব ৭৯-এ।

আমাদের tsconfig-এ verbatimModuleSyntax চালু। এটি থাকলে import { AppRouter } (type ছাড়া) লিখলে tsc সরাসরি TS1484 error দেয়, আর Vite-এও build ব্যর্থ হয় MISSING_EXPORT বলে। Option বন্ধ করে দেখা গেল, এই একই লাইন build-এ চুপচাপ মুছে গেছে, আর bundle 293.78 kB-ই রয়েছে। তাহলে option কেন? কারণ এটি ইচ্ছাকে স্পষ্ট করে, কোন import চলার সময়ের আর কোনটি নয়, ভাগ্যের ওপর ছাড়ে না।

দুটি শিক্ষা। tsc সফল মানে কোড নিরাপদ নয়: ভুল import-এ tsc 0 দিয়েছে, অথচ secret bundle-এ চলে গেছে। আর secret কখনো shared কোডের পাশে রাখবেন না; environment variable-এ রাখুন (পর্ব ৩১, পর্ব ৭৮)। CI-তে bundle-এ একটি grep ধাপ রাখাও সস্তা বীমা।

৮ · Date, superjson আর TRPCError

JSON-এ Date নেই। Server new Date() পাঠালে তারে যায় string, আর ওপারে সেটি string-ই থাকে। Map, Set, BigInt-এরও একই দশা। superjson-এর মতো transformer এই সমস্যার সমাধান করে। ধারণাটি সহজ: ডেটার পাশে একটি চিরকুট যায়, “এই ঘরটি আসলে Date.” নিজে বানিয়ে দেখা যাক।

const order = { orderId: 501, createdAt: new Date("2026-11-01T10:30:00Z") };

// ১. সাধারণ JSON: Date পথে string হয়ে যায়
const plain = JSON.parse(JSON.stringify(order));
console.log("plain:", typeof plain.createdAt, plain.createdAt instanceof Date);
try {
  console.log(plain.createdAt.getUTCFullYear());
} catch (e) {
  console.log("crash:", e.message);
}

// ২. ছোট transformer: ডেটার পাশে চিরকুট (superjson-এর ধারণা)
function serialize(obj) {
  const json = {}, meta = {};
  for (const [k, v] of Object.entries(obj)) {
    if (v instanceof Date) { json[k] = v.toISOString(); meta[k] = "Date"; }
    else json[k] = v;
  }
  return JSON.stringify({ json, meta });
}
function deserialize(text) {
  const { json, meta } = JSON.parse(text);
  for (const [k, kind] of Object.entries(meta)) {
    if (kind === "Date") json[k] = new Date(json[k]);
  }
  return json;
}

const wire = serialize(order);
console.log("wire:", wire);
const back = deserialize(wire);
console.log("back:", back.createdAt instanceof Date, back.createdAt.getUTCFullYear());
console.log("bytes:", JSON.stringify(order).length, "vs", wire.length);

আউটপুট:

plain: string false
crash: plain.createdAt.getUTCFullYear is not a function
wire: {"json":{"orderId":501,"createdAt":"2026-11-01T10:30:00.000Z"},"meta":{"createdAt":"Date"}}
back: true 2026
bytes: 54 vs 91

প্রথম দুই লাইনে Date হারিয়ে গেছে, আর method ডাকতেই crash. Transformer দিয়ে ফিরে এসেছে আসল Date. দাম ৯১ − ৫৪ = ৩৭ byte বাড়তি, এই ছোট object-এ। আসল superjson-এর রূপ প্রায় একই:

সত্যিকারের run · superjson 2.2.6 · order.create-এর উত্তর · curl

{"result":{"data":{"json":{"orderId":501,"buyer":"Rima","total":450,"createdAt":"2026-11-01T17:16:24.704Z"},"meta":{"values":{"createdAt":["Date"]},"v":1}}}}

Transformer দুই প্রান্তে লাগে: server-এ initTRPC…create({ transformer: superjson }), client-এ httpBatchLink({ transformer: superjson }). আর tRPC-র inference এখানে সত্যি কথা বলে। Transformer সরিয়ে দিলে createdAt-এর type আর Date থাকে না, string হয়ে যায়:

সত্যিকারের run · TypeScript 7.0.2 · ~/bazarghar

# শুধু server থেকে superjson সরালে
apps/web/src/Shop.tsx(26,106): error TS2339: Property 'getFullYear' does not exist on type 'string'.
apps/web/src/main.tsx(17,9): error TS2322: Type 'typeof SuperJSON' is not assignable to type 'TypeError<"You must define a transformer on your your `initTRPC`-object first">'.

সাধারণ fetch-এ এই ভুল চলার সময়ের crash. এখানে compile error. আর transformer যদি লাগাতেই না চান, API থেকে ISO string পাঠানো একটি পুরোপুরি বৈধ নকশা।

ভুল জানানো: TRPCError

TRPCError-এর code নিজেই HTTP status-এ মিলে যায়, তাই পর্ব ২৮-এর মতো status হাতে বসাতে হয় না। Node client দিয়ে সব পথ পরখ করা হলো:

সত্যিকারের run · apps/api/src/client-demo.ts, npx tsx দিয়ে · ~/bazarghar

byId 1 -> { id: 1, name: 'মিনিকেট চাল ৫ কেজি', price: 450, stock: 12 }
byId 99 -> ERROR NOT_FOUND 404
guest order -> ERROR UNAUTHORIZED 401
qty 50 -> ERROR BAD_REQUEST 400
qty 5 of oil -> ERROR CONFLICT 409
order {
  orderId: 501,
  buyer: 'Rima',
  total: 900,
  createdAt: 2026-11-01T17:16:24.627Z
} | is Date: true

qty 50 Zod-এ আটকেছে (সীমা ১ থেকে ১০)। qty 5 of oil Zod পেরিয়ে স্টকের নিয়মে আটকেছে (স্টকে ৩)। দুটি আলাদা স্তর: আকৃতির যাচাই আর ব্যবসার নিয়ম। আর শেষ লাইনের is Date: true দেখাচ্ছে transformer কাজ করেছে।

Development-এ আমাদের error-এর উত্তরে stack field চলে এসেছিল, ভেতরে পুরো ফাইলের পথসহ। tRPC-র docs অনুযায়ী NODE_ENV production না হলে isDev সত্য থাকে, আর তখনই stack পাঠানো হয়। NODE_ENV=production-এ আবার চালিয়ে দেখা গেল stack নেই। অর্থাৎ production-এ এই variable ঠিক রাখা এখানে নিরাপত্তার বিষয়। চাইলে initTRPC.create({ isDev: false }) স্পষ্ট করে লিখুন। Zod-এর ভুল field ধরে দেখাতে errorFormatter ব্যবহার করা যায়; আমাদের run-এ {"fields":{"id":["Too small: expected number to be >0"]}} এসেছে।

৯ · সীমা আর সিদ্ধান্ত — কখন কোন পথ

tRPC খুব আরামদায়ক, তাই সীমাগুলো সততার সঙ্গে জানা দরকার।

  • দুই প্রান্তে TypeScript লাগে। Kotlin, Swift বা Dart client typeof appRouter পড়তে পারে না। মোবাইল অ্যাপ React Native না হলে সে সাধারণ HTTP দিয়ে tRPC-র নিজস্ব wire format নকল করতে পারে বটে, কিন্তু সব type-সুবিধা হারায়।
  • Public API-র জন্য নয়। বাইরের partner চান স্থির URL, docs আর চুক্তি। tRPC-র URL আর batch format তার ভেতরের বিষয়। tRPC v10 থেকে v11-এ HTTP-র কিছু আচরণ বদলেছে বলে community-তে জানানো হয়েছে; এমন বদল বাইরের ব্যবহারকারীকে ভাঙবে।
  • শক্ত বাঁধন। Coupling এখানে ইচ্ছাকৃত। Backend-এর function-এর নাম বদলালেই frontend ভাঙে। এক দলে এটি উপহার, আলাদা দলে এটি ঝামেলা।
  • Versioning নেই। tRPC ধরে নেয় client আর server একসাথে deploy হয়। কিন্তু খোলা browser tab-এ পুরোনো কোড চলতে থাকে। এই version skew compiler দেখে না। আমাদের run-এ পুরোনো build নতুন server-এর সঙ্গে চলেছে, দাম ফাঁকা। নিরাপদ পথ হলো expand-contract: আগে নতুন field যোগ করুন, পুরোনোটি রাখুন, client ছড়িয়ে পড়লে তারপর পুরোনোটি মুছুন। Versioning-এর বিস্তার পর্ব ৩৫-এ।
  • বড় router-এ compile সময়। সব কিছু inference হওয়ায় অনেক procedure-এ editor ধীর হতে পারে। আমাদের ছোট project-এ দুটি tsc মিলিয়ে ০.৬ সেকেন্ডের কম লেগেছে (একটি স্থানীয় run), তবে বড় project-এর ইঙ্গিত এটি থেকে পাওয়া যায় না।
প্রশ্ন ১: বাইরের কেউ ডাকবে?partner, Kotlin, Swift
প্রশ্ন ২: দুই প্রান্তে TS, এক repo?এক দল, একসাথে deploy
প্রশ্ন ৩: অনেক screen, ভিন্ন ডেটা?নানা client, nested ডেটা
প্রশ্ন ৪: আলাদা repo, সব TS?দুই দল, এক চুক্তি
হ্যাঁ হলেREST + OpenAPI
ডেলিভারি partner-এর Java সিস্টেম,
মোবাইল অ্যাপ (Kotlin)
→ REST + OpenAPI
ভাষা-নিরপেক্ষ চুক্তি, docs, version
হ্যাঁ হলেtRPC
বাজারঘরের admin panel:
React + Express, একই monorepo
→ tRPC
generate ধাপ নেই, নাম বদল সঙ্গে সঙ্গে ধরা
হ্যাঁ হলেGraphQL
ওয়েব, মোবাইল, TV:
প্রতিটি screen ভিন্ন field চায়
→ GraphQL
client ঠিক করে কী লাগবে; দাম জটিলতা
হ্যাঁ হলেSchema package বা codegen
দুই দল, দুই repo, TypeScript
→ Versioned schema package
অথবা OpenAPI codegen
একসাথেএকটি কোম্পানিতে দুটিই থাকতে পারে
admin panel ─ tRPC ──┐
                     ├─ service layer
partner API ─ REST ──┘
         (দুটিই একই database)
  1. প্রথমে দেখুন কে ডাকবে। এই প্রশ্নটিই সবচেয়ে কঠিন বাধা। বাইরের ব্যবহারকারী বা অন্য ভাষার client থাকলে চুক্তি লাগবে। তখন REST ও OpenAPI সাধারণত নিরাপদ পছন্দ।
  2. সব আপনার হাতে? দুই প্রান্তে TypeScript, এক repo, একসাথে deploy হলে tRPC সবচেয়ে কম ঝামেলার। বাজারঘরের admin panel ঠিক এমন।
  3. Client অনেক, চাহিদা ভিন্ন? প্রতিটি screen আলাদা আকৃতির ডেটা চাইলে GraphQL-এর সুবিধা বাড়ে। দাম হলো server-এ N+1 আর cache-এর জটিলতা (পর্ব ৩৬)।
  4. TypeScript আছে, কিন্তু দল আলাদা? তখন inference-এর বদলে একটি চুক্তি ভালো: version-সহ schema package অথবা OpenAPI থেকে generate. বদল সচেতনভাবে আসে।
  5. এটি এক-বা-অন্য নয়। দুটি পথ একই service layer (পর্ব ২৯) ব্যবহার করতে পারে। Procedure আর REST controller দুটোই পাতলা থাকলে পরে পথ বদলানোও সস্তা।
বিষয়REST + OpenAPIGraphQLtRPC
চুক্তি থাকেআলাদা spec ফাইলSDL schemaTypeScript কোডেই
Client-এর ভাষাযেকোনোযেকোনোকার্যত TypeScript
Generate ধাপসাধারণত লাগেসাধারণত লাগেলাগে না
HTTP cache, CDNসহজবাড়তি কাজQuery GET হলেও URL ভিন্নধর্মী, batch বাড়তি বিবেচনা
Public APIখুব মানানসইমানানসইসাধারণত নয়

এই বিষয়ে community-তে মতভেদ আছে। অনেকে বলেন শুরুতে tRPC, দরকার হলে পরে REST. অনেকে বলেন প্রথম দিন থেকে চুক্তি। দুই পক্ষের যুক্তিই ঠিক; পার্থক্য আপনার দলের আকার আর ক্রেতার ধরনে।

পাশের দুই-তিনটি পথ

  • Next.js Server Actions: Next.js-এ tRPC-র কিছু কাজ এখন framework নিজেই করে। Server-এর একটি function client থেকে ডাকা যায়, type-সহ। পর্ব ৫৪-এ পুরোটা আসবে।
  • Hono RPC: Hono framework-এ route-এর type export করে hc<AppType>() client পাওয়া যায়, URL থাকে REST-এর মতোই। Docs অনুযায়ী দুই প্রান্তে strict লাগে। এখানে চালানো হয়নি।
  • ts-rest: REST route-এর জন্য একটি ভাগ করা contract, যার থেকে OpenAPI-ও তৈরি করা যেত। সতর্কতা: লেখার সময়ে ts-rest.com জানায় প্রকল্পটি আর রক্ষণাবেক্ষণ হচ্ছে না। নতুন project-এ নেওয়ার আগে বর্তমান অবস্থা দেখে নিন। এখানে চালানো হয়নি।
  • tRPC-তে ফাইল: v11 থেকে FormData আর বাইনারি পাঠানো যায়, তবে docs অনুযায়ী httpBatchLink-এর সঙ্গে splitLink লাগে। বড় upload-এর জন্য পর্ব ৩০-এর পথ এখনো সরল।

আরও গভীরে: আমাদের run-এ BAD_REQUEST-এর JSON উত্তরে "code":-32600 এসেছিল। সংখ্যাটি JSON-RPC 2.0 spec-এর “Invalid Request” কোড, tRPC সেখান থেকে error-এর ধারণা নিয়েছে। inference-এর খরচ আসে TypeScript-এর type instantiation থেকে: প্রতিটি generic-এর প্রতিটি ব্যবহার compiler-কে আবার হিসাব করতে হয়। বড় monorepo-তে দলগুলো TypeScript project references বা .d.ts তৈরি করে server-এর type আগে থেকে “জমিয়ে” রাখে, যাতে client-কে পুরো server কোড পড়তে না হয়। tsc --extendedDiagnostics এই খরচ মাপার সূচনা।

পরীক্ষা ও ইন্টারভিউতে:

  • tRPC কি API schema তৈরি করে? না। Client server-এর TypeScript type সরাসরি পড়ে, কোনো generate ধাপ নেই।
  • Type থাকলে Zod কেন? Type চলার সময় মুছে যায়, আর network-এর ইনপুট বিশ্বাসযোগ্য নয়।
  • Server কোড bundle-এ যায় না কেন? import type build-এ মুছে যায়, client চলে Proxy আর HTTP-তে।
  • httpBatchLink কী করে? কাছাকাছি সময়ের একাধিক ডাক এক HTTP অনুরোধে পাঠায়।
  • tRPC কখন নয়? Public API, অন্য ভাষার client, স্বাধীনভাবে deploy হওয়া দল।

১০ · AI-এর যুগে type-safe API

tRPC-র major version কয়েকবার বদলেছে, তাই AI প্রায়ই পুরোনো কোড দেয়। সুখবর হলো, এখানে compiler নিজেই AI-এর বড় পরীক্ষক। কিন্তু compiler যা দেখে না, সেটি আপনাকেই দেখতে হবে।

AI-এর ভুলকীভাবে ধরবেন
createTRPCClient({ transformer: superjson }), পুরোনো জায়গায়আসল run-এ tsc বলল: The transformer property has moved to httpLink/httpBatchLink/wsLink
@trpc/react-query-র trpc.x.useQuery() নতুন project-এDocs এটিকে “Classic” বলে: সমর্থিত, তবে নতুন project-এ সুপারিশ করে না
import { AppRouter }, type ছাড়া, বা পুরো router importverbatimModuleSyntax চালু রাখা, bundle-এ grep
Database-এর পুরো row ফেরত দেওয়াNetwork tab বা curl দিয়ে উত্তরের সব field পড়া; .output() বসানো
publicProcedure-এ লেখার কাজ, অনুমতি যাচাই নেইLogin ছাড়া ডেকে দেখা; সঠিক ফল UNAUTHORIZED
as any দিয়ে type error মুছে ফেলাDiff-এ as any আর @ts-ignore খোঁজা; inference সেখানেই কেটে যায়

নিচের prompt-এ version আর সীমা স্পষ্ট বলা আছে। শেষ দুটি লাইন খেয়াল করুন: AI-কে কোড লিখতে নয়, নিজের ঝুঁকি দেখাতে বলা হচ্ছে।

আমার npm workspaces monorepo-তে tRPC 11, Express 5, Zod 4, @trpc/tanstack-react-query আর TanStack Query 5 আছে। নিচের router-এ order.cancel নামে একটি protected mutation যোগ করো। [এখানে router.ts পেস্ট করুন, আসল secret বাদ দিয়ে] শর্ত: ১. input Zod দিয়ে যাচাই হবে, আর অর্ডারটি ctx.user-এর নিজের কি না দেখতে হবে। ২. ভুলে TRPCError ব্যবহার করো, ঠিক code সহ। ৩. কোনো নতুন package যোগ করো না। ৪. client-এ শুধু import type ব্যবহার করো। ৫. কোন কোন ফাইল বদলালে আর কেন, তা তালিকা করো। ৬. এই কোডে কোন ভুল tsc ধরবে না, সেটিও আলাদা করে বলো।

AI-এর tRPC কোড যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১RPC দূরের function-কে নিজের function-এর মতো দেখায়। এই সুবিধার ভেতরেই কি একটি বিপদ লুকিয়ে আছে?

RPC-র ধারণা পুরোনো। ১৯৮৪ সালে Andrew Birrell আর Bruce Nelson-এর “Implementing Remote Procedure Calls” গবেষণাপত্র এটিকে বাস্তব রূপ দেয়। লক্ষ্য ছিল, দূরের ডাক যেন সাধারণ function ডাকের মতো সহজ হয়। এরপর এসেছে Sun RPC, CORBA, SOAP, আর ২০১৫ সালে Google-এর gRPC. প্রায় প্রতিটি প্রজন্ম একই স্বপ্ন দেখেছে।

১৯৯৪ সালে Sun Microsystems-এর Jim Waldo ও সহকর্মীরা “A Note on Distributed Computing”-এ সতর্ক করেন: স্থানীয় ডাক আর দূরের ডাককে এক করে দেখানো একটি ভ্রান্তি। পার্থক্যগুলো মৌলিক। দূরের ডাকে সময় অনেক বেশি লাগে। Memory ভাগ করা যায় না। আর সবচেয়ে বড় কথা, আংশিক ব্যর্থতা ঘটে: ডাকটি পৌঁছেছে, কাজ হয়েছে, কিন্তু উত্তর হারিয়ে গেছে। স্থানীয় function-এ এমন হয় না।

api.order.create(…) দেখতে একটি সাধারণ function. কিন্তু উত্তর না এলে অর্ডার হয়েছে কি না, client জানে না। আবার চেষ্টা করলে দুটি অর্ডার হতে পারে। এজন্য idempotency key লাগে (পর্ব ৩৩)। লুপের ভেতরে byId ডাকলে কোড সুন্দর দেখায়, অথচ প্রতিটি ডাক network-এ যায়। tRPC-র batch এটিকে কিছুটা ঢাকে, দূর করে না।

tRPC অবশ্য একটি ক্ষেত্রে আগের প্রজন্মের চেয়ে সৎ। ডাকটি await করতে হয়, আর useQuery-তে loading ও error অবস্থা সামনেই থাকে। Network লুকানো আছে কোডের গঠনে, UI-এর আচরণে নয়।

মূল উপলব্ধি: RPC কোড লেখা সহজ করে, কিন্তু network-এর পদার্থবিদ্যা বদলায় না। ডাকটি দেখতে function হলেও ভাবতে হবে একটি চিঠির মতো: দেরিতে আসতে পারে, হারাতে পারে, দুবার পৌঁছাতে পারে।

প্র ০২চুক্তি আগে লিখে তা থেকে কোড, নাকি কোড লিখে তা থেকে চুক্তি? দুই দর্শনের লড়াইটা কোথায়?

Schema-first পথের ইতিহাস লম্বা। CORBA-র IDL, SOAP-এর WSDL, Google-এর Protocol Buffers, GraphQL-এর SDL: সবখানে একটি নিরপেক্ষ ভাষায় চুক্তি লেখা হয়, তারপর প্রতিটি ভাষার জন্য কোড তৈরি হয়। সুবিধা হলো, চুক্তিটি কোনো একটি ভাষার সম্পত্তি নয়। ইচ্ছা করেই নকশা নিয়ে আগে ভাবতে হয়, আর frontend দল backend তৈরির আগেই কাজ শুরু করতে পারে। অসুবিধা হলো, একই জিনিস দুবার লেখার মতো অনুভূতি আর একটি বাড়তি ধাপ।

Code-first পথে চুক্তি কোড থেকে বের হয়। FastAPI-তে Python type থেকে OpenAPI আসে (পর্ব ৬৯)। tRPC এর চরম রূপ: চুক্তি ফাইলই নেই, TypeScript-এর type system নিজেই চুক্তি। এটি সম্ভব হয়েছে ২০১৮ সালে TypeScript 2.8-এ আসা conditional type আর infer-এর কারণে। এর আগে type-কে প্রোগ্রামের মতো হিসাব করানো এত সহজ ছিল না।

লড়াইটি আসলে গতি বনাম ইচ্ছাকৃত নকশা-র। কোড থেকে আসা চুক্তিতে একটি ভেতরের নাম বদলালে বাইরের চুক্তিও বদলে যায়, হয়তো কেউ না চাইতেই। Hyrum Wright-এর নামে পরিচিত একটি পর্যবেক্ষণ আছে: API-র ব্যবহারকারী যথেষ্ট হলে আপনার সিস্টেমের প্রতিটি দেখা যায় এমন আচরণের ওপর কেউ না কেউ নির্ভর করবে। বাইরের জন্য তাই চুক্তিকে আলাদা, সচেতন বস্তু রাখা নিরাপদ।

আবার ভেতরের admin panel-এ, যেখানে একই মানুষ দুই পাশে কাজ করেন, চুক্তি ফাইলের ওপর আলোচনা সময়ের অপচয় হতে পারে। সেখানে নাম বদলে compiler-এর লাল দাগ ধরে ধরে সব ঠিক করা অনেক দ্রুত।

মূল উপলব্ধি: প্রশ্নটি “কোনটি সেরা” নয়, প্রশ্ন হলো সীমানাটি কত দামি। সীমানা পার হওয়া যত কঠিন, চুক্তি তত স্পষ্ট হওয়া দরকার।

প্র ০৩Compiler সবুজ, deploy-ও ঠিকঠাক। তবু ব্যবহারকারীর পাতা ভাঙে কীভাবে? Type-safety-র সীমা কোথায়?

Compiler একটি নির্দিষ্ট মুহূর্তের কোড দেখে: আপনার repository-র এখনকার অবস্থা। চলমান সিস্টেম কিন্তু একাধিক version-এর মিশ্রণ। ধরুন সকাল ১০টায় নতুন server গেল। রিমা ৯টা থেকে একটি tab খুলে রেখেছেন। তাঁর browser-এ পুরোনো JavaScript চলছে, যা price খোঁজে। Server পাঠাচ্ছে priceTaka. আমাদের run-এ ঠিক এই ঘটনাই দেখা গেছে: পাতায় দাম ফাঁকা, কোনো error নেই।

এর নাম version skew. ধাপে ধাপে deploy হলে কিছুক্ষণ পুরোনো আর নতুন server একসাথে চলে, তাই উল্টো সমস্যাও হয়: নতুন client পুরোনো server-কে ডাকে। মোবাইল অ্যাপে এই জানালা সপ্তাহ বা মাসজুড়ে খোলা থাকতে পারে, কারণ ব্যবহারকারী কবে update করবেন তা আপনার হাতে নেই। tRPC-র জন্য মোবাইল কঠিন হওয়ার এটিও একটি কারণ।

সমাধান হলো সামঞ্জস্য রেখে বদলানো, যাকে expand-contract বলে। প্রথম ধাপে server দুটি field-ই পাঠায়। দ্বিতীয় ধাপে client নতুন field পড়ে, আর সেটি সবার কাছে পৌঁছায়। তৃতীয় ধাপে log বা metrics দেখে নিশ্চিত হয়ে পুরোনো field মোছা হয়। Database-এর migration-এও একই নকশা ব্যবহার হয় (পর্ব ৪০)। tRPC-তে এই ধাপগুলোতে compiler একটু কম সাহায্য করে, কারণ সে ধরে নেয় সবাই সবসময় সর্বশেষ কোডে আছে।

আরও একটি উপায় হলো পুরোনো client-কে পাতা reload করতে বলা। অনেক অ্যাপ server থেকে build-এর পরিচয় পাঠিয়ে মেলায়, না মিললে “নতুন version এসেছে” বার্তা দেখায়।

মূল উপলব্ধি: Type-safety প্রমাণ করে একই commit-এর দুই প্রান্ত মেলে। চলমান সিস্টেমে দুটি ভিন্ন commit একসঙ্গে কথা বলে। সেই ফাঁক compiler নয়, নকশা দিয়ে বন্ধ করতে হয়।

প্র ০৪Server-এর ইনপুট Zod দিয়ে যাচাই হয়। তাহলে উত্তরও প্রতিবার যাচাই করা উচিত নয় কি?

TypeScript-এর নিজের নকশা-লক্ষ্যে স্পষ্ট বলা আছে, চলার সময়ে সে কোনো বাড়তি বোঝা যোগ করবে না। Type মুছে যায় (পর্ব ০৬)। তাই সীমানায় আলাদা যাচাই লাগে। Colin McDonnell ২০২০ সালের দিকে Zod তৈরি করেন ঠিক এই ফাঁকের জন্য: একবার schema লিখে যাচাই আর type দুটোই পাওয়া। ইনপুটে যাচাই নিয়ে বিতর্ক নেই, কারণ ইনপুট আসে অবিশ্বস্ত জগৎ থেকে।

উত্তর ভিন্ন। আপনার resolver-এর return type compiler আগেই জানে, তাই আকৃতির ভুল প্রায় সবসময় compile-এ ধরা পড়ে। তাহলে .output() কেন? তিনটি কারণে। প্রথমত, ফাঁস ঠেকানো: আমাদের পরীক্ষায় Zod object costPrice ফেলে দিয়েছে। এটি শেষ দেয়াল। দ্বিতীয়ত, type ফাঁকি: কোথাও as any বা JSON.parse থাকলে compiler অন্ধ, চলার সময়ের যাচাই ধরে। তৃতীয়ত, বাইরের ডেটা: অন্য service-এর উত্তর পাঠালে তার আকৃতি নিশ্চিত নয়।

দাম হলো CPU. প্রতিটি উত্তরে একবার করে পুরো object হাঁটতে হয়। বড় তালিকায় এটি মাপার মতো হতে পারে, যদিও সাধারণ API-তে database-এর সময়ের তুলনায় তা ছোট বলেই ধরা হয়; নিজের project-এ মেপে দেখাই ভালো। ব্যর্থ হলে client সাধারণত INTERNAL_SERVER_ERROR পায়, কারণ দোষটি server-এর।

একটি মাঝামাঝি নিয়ম কাজে আসে। গোপন field-ওয়ালা জিনিস, অর্থাৎ user, product-এর কেনা দাম, payment, এসবের উত্তরে .output() রাখুন, অথবা database query-তেই শুধু দরকারি column নিন (পর্ব ৪২)। শুধু খোলা ডেটার ক্ষেত্রে inference-ই যথেষ্ট হতে পারে।

মূল উপলব্ধি: ইনপুট যাচাই করা হয় অবিশ্বাস থেকে, উত্তর যাচাই করা হয় নিজের ভুলের ভয় থেকে। দুটির কারণ আলাদা, তাই সিদ্ধান্তও আলাদা।

প্র ০৫Monorepo আর tRPC কি দলের গঠনের ওপরও প্রভাব ফেলে? “শক্ত বাঁধন” কখন উপহার, কখন বোঝা?

১৯৬৮ সালে Melvin Conway “How Do Committees Invent?” প্রবন্ধে লেখেন, সংস্থা যে সিস্টেম বানায়, তার গঠন সংস্থার যোগাযোগের গঠনের নকল হয়। একে এখন Conway's law বলা হয়। API-র সীমানা তাই শুধু প্রযুক্তিগত নয়, এটি দুই দলের মধ্যে একটি সামাজিক চুক্তি।

এক বা দুজনের দলে frontend আর backend আসলে একই মাথায়। সেখানে আলাদা চুক্তি মানে নিজের সঙ্গে নিজে চিঠি লেখা। tRPC-র শক্ত বাঁধন এখানে উপহার: backend-এর নাম বদলের সঙ্গে একই pull request-এ frontend ঠিক হয়, আর compiler প্রমাণ করে কিছু বাদ পড়েনি। বিপরীতে পর্ব ০৪-এর ধারণা মনে করুন: যা একসাথে বদলায়, তা একসাথে রাখা ভালো।

দল বড় হলে ছবি বদলায়। Payment দলের একটি নাম বদল যদি অন্য দশটি দলের build ভাঙে, তখন সেই বাঁধন বোঝা। স্বাধীনভাবে deploy করার ক্ষমতা হারিয়ে যায়, যা অনেক সংস্থার কাছে গতির মূল উৎস। তখন কেউ কেউ tRPC-কে শুধু একটি BFF হিসেবে রাখেন: নির্দিষ্ট ওয়েব অ্যাপের ঠিক সামনে একটি পাতলা tRPC স্তর, যার পেছনে অন্য service-গুলো স্থির চুক্তিতে কথা বলে। এতে ওয়েব দল গতি পায়, আর ভেতরের সীমানা শক্ত থাকে।

Monorepo নিজেও দাম চায়। Build-এর সময় বাড়ে, access control জটিল হয়, CI-তে কেবল বদলে যাওয়া package চালানোর ব্যবস্থা লাগে। কিছু বড় প্রতিষ্ঠান monorepo-তে বিপুল বিনিয়োগ করে সুবিধা পায়, ছোট দলের জন্য npm workspaces সাধারণত যথেষ্ট।

মূল উপলব্ধি: বাঁধন ভালো বা খারাপ নয়, প্রশ্ন হলো সেটি কার সঙ্গে। একই দলের ভেতরে শক্ত বাঁধন গতি দেয়, দলের সীমানা পেরিয়ে সেটি গতি কেড়ে নেয়। API-র নকশা তাই দলের মানচিত্র দেখে করতে হয়।

অনুশীলন

হিসাব, পর্যালোচনা, ফলাফল অনুমান আর একটি কমান্ডের কাজ। আগে নিজে চেষ্টা করুন।

  1. Bundle-এর হিসাব: একটি client-এর bundle gzip-এ ৯০.৭৭ kB. পুরো router ভুল করে import করায় তা হলো ১১৪.১৮ kB. (ক) বৃদ্ধি কত kB আর কত শতাংশ? (খ) ব্যবহারকারীর গতি ২ Mbps ধরে শুধু নামাতে বাড়তি কত সময় লাগে? (গ) দিনে ৫০,০০০ বার নতুন করে bundle নামানো হলে (অনুমান) দিনে বাড়তি কত MB যায়?

    (ক) ১১৪.১৮ − ৯০.৭৭ = ২৩.৪১ kB. শতাংশ: ২৩.৪১ ÷ ৯০.৭৭ × ১০০ ≈ ২৫.৮%.

    (খ) ২৩.৪১ kB = ২৩.৪১ × ৮ = ১৮৭.২৮ kbit. ২ Mbps = ২০০০ kbit/s. সময় = ১৮৭.২৮ ÷ ২০০০ ≈ ০.০৯৪ সেকেন্ড, প্রায় ৯৪ ms.

    (গ) ২৩.৪১ × ৫০,০০০ = ১১,৭০,৫০০ kB ≈ ১১৭০.৫ MB (১ MB = ১০০০ kB ধরে)।

    তবে এই ক্ষেত্রে আকার আসল সমস্যা নয়। আসল সমস্যা হলো server-এর secret আর কেনা দাম সবার হাতে চলে যাওয়া।

  2. কোড পর্যালোচনা: নিচের procedure-এ অন্তত চারটি সমস্যা খুঁজুন: getOrders: publicProcedure.query(({ input }: any) => db.order.findMany({ where: { userId: input.userId } }))
    • অনুমতি নেই: publicProcedure, তাই login ছাড়াই ডাকা যায়। হওয়া উচিত protectedProcedure.
    • অন্যের ডেটা: userId আসছে client থেকে। যে কেউ অন্যের নম্বর পাঠিয়ে অর্ডার দেখতে পারে। ctx.user.id ব্যবহার করতে হবে। একে বলে broken access control, দেখুন পর্ব ৪৮।
    • যাচাই নেই: .input() নেই, আর any সব type-সুবিধা মুছে দিয়েছে।
    • সীমা নেই: findMany-এ কোনো take নেই, তাই বিশাল তালিকা আসতে পারে (পর্ব ৩৪)।
    • পুরো row: সব column ফেরত যাচ্ছে, হয়তো এমন কিছুও যা দেখানোর কথা নয়। select বা .output() লাগবে।
  3. আউটপুট অনুমান করুন: Node.js-এ console.log(JSON.parse(JSON.stringify({ at: new Date(0), note: undefined, tags: new Set([1, 2]) }))) কী ছাপবে? কোন কোন তথ্য হারাল?

    সত্যিকারের run-এর ফল:

    { at: '1970-01-01T00:00:00.000Z', tags: {} }

    তিনটিই বদলেছে। at এখন string. note পুরোপুরি গায়েব, কারণ JSON.stringify undefined-এর field বাদ দেয়। tags হয়েছে ফাঁকা object, Set-এর সব মান হারিয়ে গেছে। superjson এই তিনটিই ফিরিয়ে আনে, কারণ সে meta ঘরে আসল ধরন লিখে রাখে।

  4. কমান্ডের কাজ: বাজারঘরের মতো monorepo-তে order.create-এর উত্তরের total বদলে totalTaka করবেন, আর production-এ browser tab খোলা থাকতে পারে। কোন ক্রমে কী করবেন, প্রতি ধাপে কোন কমান্ড চালাবেন?
    1. Expand: server-এ দুটি field-ই ফেরত দিন: { total, totalTaka: total }. npm run typecheck সবুজ থাকবে। Deploy করুন।
    2. Client: apps/web-এ buy.data.total বদলে totalTaka করুন। npm run build চালিয়ে deploy করুন।
    3. অপেক্ষা: পুরোনো build-এর ব্যবহার কমেছে কি না log-এ দেখুন। Tab-এর আয়ু আপনার অ্যাপের ওপর নির্ভর করে।
    4. Contract: server থেকে total মুছুন। আবার npm run typecheck. কোথাও পুরোনো নাম থাকলে এখনই TS2339 আসবে। Deploy করুন।

    এক ধাপে নাম বদলালে compiler খুশি থাকত, কিন্তু খোলা tab-গুলোতে মোট ফাঁকা দেখাত।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি tRPC query-র ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. tsc server-এর type দেখে input মিলিয়ে নেয়
  2. Browser-এ Proxy procedure-এর path জমায়
  3. httpBatchLink HTTP অনুরোধ পাঠায়
  4. Adapter context তৈরি করে, middleware চলে
  5. Zod চলার সময় ইনপুট যাচাই করে
  6. Resolver উত্তর বানায়, client cache-এ রেখে render করে

TypeScript client-এ লেখা হলো const p = (await res.json()) as Product;. Server price-এর নাম বদলে priceTaka করল। কী ঘটবে?

  • চলার সময় as error দেবে
  • tsc চুপ থাকবে, চলার সময় p.price হবে undefined
  • tsc সঙ্গে সঙ্গে error দেবে
  • res.json() নাম মিলিয়ে নেবে

tRPC-তে .input(z.object({ id: z.number() })) ব্যবহার করা হলো। চলার সময় browser থেকে { id: "abc" } এলে কী হবে?

  • TypeScript compiler অনুরোধটি থামাবে
  • Zod string-কে সংখ্যায় বদলে resolver চালাবে
  • Zod ব্যর্থ হবে, client পাবে BAD_REQUEST (400)
  • Resolver চলবে, পরে 500 আসবে

React client-এ শুধু import type { AppRouter } from "@bazarghar/api/router"; আছে। Router ফাইলে একটি secret string আছে। Build-এর পরে bundle-এ কী পাওয়া যাবে?

  • Router-এর কোনো কোড বা secret পাওয়া যাবে না
  • পুরো router আর secret পাওয়া যাবে
  • Secret বাদে router-এর type পাওয়া যাবে
  • Bundler secret চিনে মুছে দেবে, বাকি কোড থাকবে

Server-এ একটি field-এর নাম বদলানো হলো। tsc -p apps/web error দিচ্ছে, অথচ vite build সফল। কেন?

  • TypeScript-এ বাগ আছে
  • Vite পুরোনো cache থেকে build করেছে
  • tRPC চলার সময় নাম ঠিক করে নেবে
  • Vite type যাচাই করে না, শুধু type মুছে bundle বানায়

tRPC-র একটি procedure { createdAt: new Date() } ফেরত দেয়। কোনো transformer নেই। Client-এ createdAt-এর inferred type কী, আর কেন?

  • Date, কারণ server-এ তা-ই আছে
  • any, কারণ JSON-এর type নেই
  • string, কারণ JSON-এ Date string হয়ে যায়
  • number, কারণ Date একটি timestamp

একটি অনলাইন শপ ডেলিভারি partner-কে API দেবে, যাদের সিস্টেম Java-তে লেখা। কোন পথ সবচেয়ে মানানসই?

  • tRPC, কারণ এতে generate ধাপ লাগে না
  • REST আর OpenAPI চুক্তি
  • npm workspace-এর ভাগ করা package
  • Next.js Server Actions

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. as Product একটি প্রতিশ্রুতি, যাচাই নয়। ফাঁক বন্ধের তিন পথ: ভাগ করা schema, code generation, inference.
  2. tRPC: client import type করে AppRouter পড়ে, আর চলার সময় চলে Proxy আর HTTP-তে।
  3. Type মুছে যায়, তাই ইনপুটে Zod আর উত্তরে সচেতন বাছাই লাগে। Inference কোনো ফিল্টার নয়।
  4. vite build type দেখে না। tsc && vite build রাখুন, আর bundle-এ grep করে যাচাই করুন।
  5. tRPC মানায় এক দলের TypeScript অ্যাপে। Public API, মোবাইল আর version skew-এর জন্য চুক্তি ও expand-contract ভাবুন।

পরের পর্বে: API-র নকশা শেষ, এবার ডেটা কোথায় থাকবে। মডিউল ৬ শুরু হচ্ছে ORM-এর ধারণা দিয়ে: object আর table-এর মিল, query builder বনাম raw SQL. আজকের products array সেখানে আসল database-এ যাবে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান