توسعه فرانت‌اند

الگوهای پیشرفته TypeScript برای توسعه‌دهندگان: از طراحی Type-safe تا معماری APIهای پایدار

کاربرد Generics، Union و satisfies با مرز Runtime؛ اصلاح سوءبرداشت‌های Type-safe و نمونه کامل بررسی DTO، تبدیل Domain و مدیریت خطا.

1404/10/25 3 دقیقه مطالعه 1,890 بازدید

TypeScript برای روشن کردن رابطه داده‌ها و قرارداد توابع مفید است، اما خطاهای منطقی یا داده نامعتبر بیرونی را به طور کامل حذف نمی‌کند. نوع‌ها معمولاً هنگام تولید JavaScript حذف می‌شوند و پاسخ API همچنان نیازمند بررسی Runtime است. ادعای اینکه شکست سیستم از نظر نوعی غیرممکن می‌شود یا Branded Type نرخ باگ را با درصد ثابت کم می‌کند پشتوانه‌ای در این آموزش ندارد. هدف، قراردادهایی است که اشتباه مشخص را زودتر نشان دهند و نگهداری آن‌ها برای تیم قابل فهم باشد.

الگوی پیشرفته را از مسئله واقعی انتخاب کنید. Generic وقتی رابطه ورودی و خروجی را حفظ می‌کند ارزش دارد؛ Conditional Type برای رابطه شرطی مفید است؛ Union می‌تواند حالت‌های مجاز را جدا کند. استفاده هم‌زمان از همه این ابزارها برای یک تابع ساده، کیفیت را تضمین نمی‌کند. خواننده بعدی باید بتواند خطا را بفهمد و قرارداد را تغییر دهد. یک نوع پیچیده که فقط نویسنده‌اش می‌فهمد، ممکن است هزینه بیشتری از خطایی داشته باشد که قرار بود جلوگیری کند.

مرز داده بیرونی را با unknown مشخص کنید

پاسخ شبکه، فایل و ورودی کاربر را پیش از بررسی معتبر فرض نکنید. نوشتن یک interface برای پاسخ، داده واقعی را تغییر نمی‌دهد. unknown اجازه می‌دهد داده دریافت شود اما پیش از استفاده، ساختار آن بررسی شود. Type Guard می‌تواند نتیجه بررسی را به Compiler منتقل کند، ولی خود تابع باید واقعاً همان شرط را اجرا کند. نوشتن یک Predicate که همیشه true برمی‌گرداند حفاظت ایجاد نمی‌کند. مرز اعتماد در رفتار Runtime است، نه فقط در نام تابع.

در نمونه سفارش، شناسه باید رشته غیرخالی، تعداد عدد صحیح مثبت و زمان مقدار قابل تبدیل به تاریخ باشد. این قواعد آموزشی‌اند و تمام شرایط یک فروشگاه واقعی را پوشش نمی‌دهند. ممکن است محصول شما محدودیت طول شناسه، محدوده تعداد یا قالب زمانی سخت‌گیرانه‌تری لازم داشته باشد. پس از بررسی، DTO را به مدل داخلی تبدیل می‌کنیم. DTO تاریخ را به صورت رشته حمل می‌کند و Domain آن را Date نگه می‌دارد؛ این تفاوت را در قرارداد واضح ثبت کنید.

Generic رابطه نوع را حفظ می‌کند

در مستندات Generics، پارامتر نوع برای بیان رابطه میان بخش‌های قرارداد به کار می‌رود. تابع تبدیل Result در نمونه، نوع مقدار موفق را از T به U تغییر می‌دهد و نوع خطا E را حفظ می‌کند. این رابطه از خروجی any روشن‌تر است. Constraint هم می‌تواند شکل مورد نیاز ورودی را محدود کند، اما شرط تجاری یا مجوز دسترسی را اجرا نمی‌کند. داشتن فیلد id در نوع به معنی مالک بودن کاربر نسبت به آن شناسه نیست.

Default Generic باید با انتظار واقعی قرارداد سازگار باشد. انتخاب any به عنوان پیش‌فرض، بررسی بسیاری از استفاده‌ها را ضعیف می‌کند؛ unknown در مرز داده معمولاً صریح‌تر است. اگر Generic فقط یک بار در امضای تابع آمده و هیچ رابطه‌ای را حفظ نمی‌کند، درباره ضرورتش دوباره فکر کنید. نام پارامتر نوع هم با پیچیدگی تابع هماهنگ باشد. در قرارداد بزرگ، نام‌هایی مثل TValue یا TError ممکن است از چند حرف بی‌زمینه خواناتر باشند؛ قاعده نام‌گذاری تیم مهم‌تر از نمایش مهارت است.

حالت‌های مجاز را با Discriminated Union جدا کنید

Result در نمونه دو حالت دارد: ok برابر true همراه value، یا ok برابر false همراه error. این شکل اجازه نمی‌دهد در همان قرارداد یک نتیجه موفق بدون مقدار تعریف شود. در راهنمای Narrowing، بررسی فیلد متمایزکننده به محدود شدن نوع کمک می‌کند. اگر هر دو فیلد را اختیاری در یک object بگذارید، حالت‌های مبهم بیشتری ایجاد می‌شوند و مصرف‌کننده باید نبود هم‌زمان یا حضور هم‌زمان آن‌ها را هم مدیریت کند.

برای سفارش، Union وضعیت‌های پرداخت‌شده و منتظر پرداخت می‌تواند داده لازم هر وضعیت را جدا کند. هنوز باید انتقال بین وضعیت‌ها در Runtime کنترل شود. Compiler نمی‌داند پرداخت واقعی انجام شده یا موجودی کافی است مگر کد و منبع معتبر آن را بررسی کنند. Result نیز جای همه exceptionها را نمی‌گیرد؛ خطای پیش‌بینی‌شده دامنه با خرابی غیرمنتظره زیرساخت فرق دارد. مرز تبدیل exception به خطای قابل نمایش باید مشخص باشد تا خطای برنامه‌نویسی خاموش و به نتیجه عادی تبدیل نشود.

نمونه کامل از بررسی DTO تا مدل داخلی

فایل زیر را main.ts بنامید و در پوشه آموزشی جدا قرار دهید. Node.js و npm پیش‌نیاز اجرای روش زیر هستند. ابتدا npm init -y و سپس npm install --save-dev typescript را اجرا کنید. با فرمان npx tsc main.ts --strict --target ES2022 --module commonjs --outDir dist --noEmitOnError فایل را بسازید و با node dist/main.js اجرا کنید. این تنظیم برای package پیش‌فرض CommonJS است؛ در پروژه‌ای با type: module باید تنظیم خروجی و شیوه اجرا را با همان پروژه هماهنگ کنید.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type OrderDTO = { id: string; quantity: number; createdAt: string };
type Order = { id: string; quantity: number; createdAt: Date };
type SuccessValue<R> = R extends { ok: true; value: infer V } ? V : never;
type ParsedOrder = SuccessValue<Result<Order, string>>;
const config = { currency: "IRR", retryCount: 2 } satisfies {
  currency: "IRR" | "USD";
  retryCount: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}
function decodeOrder(value: unknown): Result<OrderDTO, string> {
  if (!isRecord(value)) return { ok: false, error: "Expected an object" };
  if (typeof value.id !== "string" || value.id.trim() === "")
    return { ok: false, error: "id must be a non-empty string" };
  if (typeof value.quantity !== "number" || !Number.isSafeInteger(value.quantity) || value.quantity <= 0)
    return { ok: false, error: "quantity must be a positive safe integer" };
  if (typeof value.createdAt !== "string")
    return { ok: false, error: "createdAt must be a string" };
  return { ok: true, value: { id: value.id, quantity: value.quantity, createdAt: value.createdAt } };
}
function mapResult<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: transform(result.value) } : result;
}
function toDomain(dto: OrderDTO): Result<Order, string> {
  const createdAt = new Date(dto.createdAt);
  if (Number.isNaN(createdAt.getTime())) return { ok: false, error: "createdAt is not a valid date" };
  return { ok: true, value: { id: dto.id, quantity: dto.quantity, createdAt } };
}
function parseOrder(value: unknown): Result<ParsedOrder, string> {
  const decoded = decodeOrder(value);
  return decoded.ok ? toDomain(decoded.value) : decoded;
}
const inputs: unknown[] = [
  { id: "order-1", quantity: 2, createdAt: "2026-01-01T00:00:00Z" },
  { id: "", quantity: -1, createdAt: "invalid" },
];
for (const input of inputs) {
  const result = mapResult(parseOrder(input), order => ({
    id: order.id, quantity: order.quantity, createdAt: order.createdAt.toISOString(), currency: config.currency,
  }));
  if (result.ok) console.log("ready", result.value);
  else console.log("invalid", result.error);
}

نمونه دو ورودی دارد: یکی معتبر و دیگری با شناسه خالی و تعداد نامعتبر. خروجی اول وضعیت ready، شناسه و زمان ISO را نشان می‌دهد؛ خروجی دوم وضعیت invalid و پیام بررسی را نمایش می‌دهد. هیچ درخواست شبکه یا پرداختی انجام نمی‌شود. تابع isRecord فقط شکل object غیرnull و غیرArray را تشخیص می‌دهد و decodeOrder فیلدهای لازم را بررسی می‌کند. سپس toDomain تاریخ را تبدیل می‌کند و اگر قابل تفسیر نباشد، Result خطا می‌دهد. وابستگی Runtime خارجی در این فایل وجود ندارد.

mapResult خطای قبلی را بدون تبدیل مقدار عبور می‌دهد. این رفتار برای زنجیره‌ای مفید است که فقط پس از موفقیت باید مرحله بعد را انجام دهد. config با satisfies یک قرارداد دارای کلیدهای required را بررسی می‌کند. تغییر نمونه به API واقعی نیازمند دریافت پاسخ به عنوان unknown و اتصال همان مرز بررسی است. اضافه کردن نام interface کنار JSON.parse، چنین اتصالی ایجاد نمی‌کند. اگر قواعد دامنه بیشتر شدند، بررسی Runtime را همراه نوع‌ها تغییر دهید تا دو تعریف از قرارداد شکل نگیرد.

satisfies با Type Assertion یک کار نمی‌کند

معرفی رسمی satisfies توضیح می‌دهد که این عملگر سازگاری با یک نوع را بررسی می‌کند و اطلاعات استنباط‌شده عبارت را حفظ می‌کند. Type Assertion با as می‌تواند درباره نوع به Compiler اعلامی بدهد که در Runtime بررسی نشده است. این دو ابزار قابل جایگزینی همگانی نیستند. as همیشه ممنوع نیست، اما باید دلیل و مرز اعتماد آن معلوم باشد. بررسی نوعی satisfies نیز داده شبکه را در زمان اجرا اعتبارسنجی نمی‌کند.

در متن قدیمی، Record با کلید string قرار بود نبود currency را تشخیص دهد. چنین قراردادی وجود یک کلید خاص را الزام نمی‌کند؛ نوعی با currency و retryCount اجباری این انتظار را بیان می‌کند. این تفاوت کوچک برای طراحی API مهم است. شکل مورد نیاز را دقیق تعریف کنید و سپس satisfies را به کار ببرید. اگر قرارداد خیلی باز باشد، استفاده از عملگر دقیق‌تر هم شرطی را که در قرارداد نیامده ایجاد نمی‌کند. نام ابزار جای تعریف درست داده را نمی‌گیرد.

Mapped و Conditional Types را محدود به نیاز نگه دارید

Mapped Types برای ساخت نوع از روی کلیدهای نوع دیگر مناسب‌اند. می‌توان مدل خواندنی، اختیاری یا نگاشت handlerها را از قرارداد پایه ساخت و از تکرار دستی کاست. اما حذف تکرار نباید تفاوت واقعی API و Domain را مخفی کند. فیلدی که در انتقال اختیاری است شاید در مدل داخلی پس از تکمیل لازم باشد. تولید خودکار همه نوع‌ها از یک مدل پایگاه داده، مرز مسئولیت لایه‌ها را تضمین نمی‌کند.

Conditional Types رابطه شرطی نوع‌ها و infer را توضیح می‌دهد. در نمونه، SuccessValue مقدار شاخه موفق Result را استخراج می‌کند و هیچ رفتار Runtime تازه‌ای ایجاد نمی‌کند. توزیع شرط روی Union و استفاده از never می‌تواند نتیجه‌ای متفاوت از انتظار بدهد؛ نوع‌های کوچک‌تر و نام‌دار فهم آن را آسان‌تر می‌کنند. اگر یک Union ساده همان قرارداد را بیان می‌کند، ساختن چند سطح شرط برای نمایش پیچیدگی ضرورتی ندارد.

Branded Type و بازبینی قرارداد

Branded Type می‌تواند اشتباه جابه‌جا کردن دو شناسه رشته‌ای را در کد بررسی‌شده کاهش دهد، اما مقدار خام را معتبر نمی‌کند. Constructor یا Parser باید رشته را بررسی کند و مرز ایجاد برند محدود باشد. اگر هر نقطه برنامه با as نوع برنددار بسازد، این تمایز آسان دور زده می‌شود. برای زمان، پول و واحد اندازه نیز تعریف دامنه مهم است؛ عدد بودن قیمت نمی‌گوید واحد ریال است یا تومان. قرارداد و نام‌ها باید این تصمیم را روشن کنند.

در Code Review، بپرسید نوع چه خطای مشخصی را نشان می‌دهد، داده بیرونی کجا بررسی می‌شود و مصرف‌کننده چگونه خطا را مدیریت می‌کند. ممنوعیت مطلق any یا as جای فهم دلیل استفاده را نمی‌گیرد. پیچیدگی استنباط و کیفیت پیام خطا هم بخشی از نگهداری‌اند. مقاله Python و JavaScript در وب برای مرز نقش زبان‌ها و طراحی فرم برای ارائه خطای قابل اقدام مفید است. قرارداد را همراه رفتار واقعی تغییر دهید تا Type-safe عنوانی برای شکل داده باقی نماند.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!