اخبار وب

Zod و اعتبارسنجی Type-safe؛ از داده خام تا قرارداد قابل اعتماد

با Zod 4 داده Runtime را اعتبارسنجی کنید؛ مثال کامل TypeScript، safeParse، تبدیل ورودی فرم، تایپ ورودی و خروجی و پیام خطای فارسی را ببینید.

1404/12/18 2 دقیقه مطالعه 1,359 بازدید
Zod و اعتبارسنجی Type-safe؛ از داده خام تا قرارداد قابل اعتماد

در فرم ثبت‌نام، تعداد بلیت یک رشته است؛ در مدل سفارش، همان مقدار باید عدد باشد. کاربر هم ممکن است مقدار خالی، عدد خارج از محدوده یا داده‌ای با فیلد اضافی بفرستد. داشتن یک interface در TypeScript این ورودی‌ها را بررسی نمی‌کند. Zod برای چنین مرزی مفید است: جایی که داده از بیرون برنامه می‌آید و پیش از استفاده باید شکل و قواعد مشخصی داشته باشد.

ارزش Zod به شعار «همه از آن استفاده می‌کنند» وابسته نیست. با تعریف schema می‌توان بخشی از قرارداد داده را در زمان اجرا بررسی کرد و تایپ متناظر را برای کد TypeScript به دست آورد. اما این قرارداد همه مسائل محصول را حل نمی‌کند. معتبر بودن شکل سفارش، موجود بودن کالا یا مجاز بودن کاربر برای خرید را ثابت نمی‌کند. این تفاوت را در مثال کامل زیر روشن نگه می‌داریم.

اعتبارسنجی Runtime با تایپ فرق دارد

TypeScript هنگام توسعه درباره رابطه تایپ‌ها بازخورد می‌دهد. پاسخ شبکه، داده فایل یا مقدار فرم در زمان اجرای برنامه می‌رسد. اگر این داده را صرفاً با as به تایپ دلخواه نسبت دهیم، بررسی عملی اضافه نشده است. ورودی نامطمئن بهتر است تا زمان بررسی unknown باقی بماند. راهنمای TypeScript و Type-level Programming این مرز را با نمونه Union توضیح می‌دهد.

Zod در همان مرز اجرا می‌شود و نتیجه‌ای مشخص برمی‌گرداند. این نتیجه می‌تواند ورودی پذیرفته‌شده یا اطلاعات خطا باشد. برنامه پس از موفقیت باید از داده حاصل استفاده کند، نه اینکه ورودی خام را دوباره وارد منطق اصلی کند. اگر schema فاصله‌های عنوان را حذف کرده یا رشته عدد را تبدیل کرده، دور زدن خروجی آن باعث می‌شود قرارداد تعریف‌شده عملاً نادیده گرفته شود.

Schema را از قواعد واقعی محصول شروع کنید

ابتدا بگویید چه داده‌ای لازم است. برای یک ثبت‌نام فرضی، نام نمایش، ایمیل، تعداد بلیت و پذیرش شرایط داریم. نام نباید خالی باشد، تعداد باید در محدوده انتخاب‌شده قرار گیرد و پذیرش شرایط باید مقدار true داشته باشد. این‌ها قواعد مثال‌اند؛ لازم نیست هر محصول همان محدوده و همان فیلدها را داشته باشد. تعریف schema باید با قرارداد واقعی فرم و سرور هماهنگ باشد.

در مرجع رسمی Schema API، نوع‌های پایه، اشیا و روش‌های تبدیل توضیح داده شده‌اند. از آن‌ها برای بیان همین قرارداد استفاده کنید. محدودیت دلخواه را فقط برای طولانی‌تر شدن schema اضافه نکنید. مثلاً یک الگوی نام که فقط حروف لاتین می‌پذیرد، ممکن است نام فارسی معتبر را رد کند؛ در چنین حالتی ابزار درست کار می‌کند، اما قانون طراحی‌شده شما اشتباه است.

parse و safeParse چه تفاوتی دارند؟

طبق راهنمای کاربرد پایه Zod، parse هنگام شکست خطا ایجاد می‌کند و safeParse نتیجه موفق یا ناموفق را در یک شیء مشخص برمی‌گرداند. برای مسیری که نامعتبر بودن ورودی اتفاق عادی است، شاخه‌های safeParse می‌توانند خوانا باشند. مهم‌تر از انتخاب یکی، رسیدگی روشن به هر دو نتیجه است. نباید خطا را خاموش کنیم و برنامه را با داده نامطمئن ادامه دهیم.

اگر بررسی یا تبدیل شما asynchronous باشد، روش مناسب async لازم می‌شود. برای مثال، بررسی موجود بودن نام کاربری می‌تواند به داده بیرونی وابسته باشد. اما چنین بررسی‌ای جای constraint پایگاه داده را نمی‌گیرد؛ دو درخواست ممکن است در فاصله کوتاهی یک نام را انتخاب کنند. schema شکل و بعضی قواعد را بررسی می‌کند، در حالی که ثبت نهایی باید قرارداد هم‌زمانی خودش را داشته باشد.

مثال کامل ثبت‌نام با ورودی و خروجی متفاوت

فایل زیر را با نام registration.ts در یک پوشه تازه ذخیره کنید. به Node.js و npm نیاز دارید. دستورات نصب و اجرا بعد از کد آمده‌اند. نمونه در ترمینال کار می‌کند و HTML، سرور یا فایل دیگری لازم ندارد. سه ورودی ساختگی دارد: ثبت‌نام معتبر، داده نامعتبر و یک ورودی با کلید اضافی. هیچ ایمیلی فرستاده و هیچ ثبت‌نامی ذخیره نمی‌شود؛ هدف فقط نمایش قرارداد اعتبارسنجی است.

import * as z from "zod";

const Registration = z.strictObject({
  displayName: z.string({ error: "نام باید متن باشد." }).trim()
    .min(2, { error: "نام باید دست‌کم دو نویسه داشته باشد." })
    .max(80, { error: "نام بیش از حد طولانی است." }),
  email: z.email({ error: "ایمیل را با قالب معتبر وارد کنید." }),
  tickets: z.string({ error: "تعداد بلیت را به‌صورت متن فرم وارد کنید." }).trim()
    .regex(/^[0-9]+$/, { error: "تعداد باید فقط رقم لاتین باشد." })
    .transform(Number)
    .pipe(z.number().int().min(1, { error: "حداقل یک بلیت انتخاب کنید." })
      .max(5, { error: "حداکثر پنج بلیت مجاز است." })),
  acceptedTerms: z.literal(true, { error: "برای ادامه، شرایط را بپذیرید." }),
});

type RegistrationInput = z.input<typeof Registration>;
type RegistrationOutput = z.output<typeof Registration>;

function describeRegistration(data: RegistrationOutput): string {
  return `${data.displayName}: ${data.tickets} بلیت؛ نوع تعداد ${typeof data.tickets}`;
}

const validInput: RegistrationInput = {
  displayName: "  سارا احمدی  ", email: "sara@example.com",
  tickets: "2", acceptedTerms: true,
};

const samples: unknown[] = [
  validInput,
  { displayName: " ", email: "invalid", tickets: "0", acceptedTerms: false },
  { ...validInput, internalRole: "admin" },
];

for (const input of samples) {
  const result = Registration.safeParse(input);
  if (result.success) {
    console.log(describeRegistration(result.data));
  } else {
    for (const issue of result.error.issues) {
      const field = issue.path.length ? issue.path.join(".") : "فرم";
      console.log(`${field}: ${issue.message}`);
    }
  }
  console.log("---");
}
npm init -y
npm install zod@4
npm install --save-dev typescript@6 tsx@4
npx tsx registration.ts

نمونه اول تعداد بلیت را به عدد تبدیل می‌کند و نام را بدون فاصله‌های ابتدا و انتها برمی‌گرداند. نمونه دوم پیام‌های مربوط به چند فیلد را نمایش می‌دهد. نمونه سوم به دلیل کلید اضافی رد می‌شود، چون در این قرارداد از strictObject استفاده کرده‌ایم. این انتخاب برای روشن شدن مثال است؛ در بعضی APIها نادیده گرفتن کلیدهای اضافه برای سازگاری لازم می‌شود. سیاست کلیدهای ناشناخته باید عمدی باشد.

چرا تبدیل مستقیم عدد همیشه مناسب نیست؟

مقادیر فرم اغلب رشته‌اند. تبدیل با Number قواعد JavaScript را دارد؛ بعضی ورودی‌ها ممکن است به عددی تبدیل شوند که از نظر فرم انتظار نداشته‌ایم. در این مثال، پیش از تبدیل تعداد، رشته را به رقم‌های لاتین محدود کرده‌ایم. بنابراین ورودی خالی یا متن ترکیبی پذیرفته نمی‌شود. برای فرم فارسی واقعی، باید درباره پذیرش رقم فارسی و تبدیل آن نیز قرارداد جدا تعریف کنید و پیام متناسب بنویسید.

تبدیل داده نباید مبهم باشد. اگر کاربر «۱» فارسی می‌نویسد و محصول آن را می‌پذیرد، همان رفتار باید در frontend و backend هماهنگ باشد. اگر ایمیل را به حروف کوچک تبدیل می‌کنید، بدانید قرارداد ذخیره و مقایسه شما چیست. یک transform فقط کد کوتاه‌تر نیست؛ معنای داده را تغییر می‌دهد. این تصمیم‌ها بهتر است با نمونه ورودی و خروجی در مستندات تیم ثبت شوند.

تایپ ورودی و خروجی را جدا بشناسید

وقتی transform داریم، نوع ورودی ممکن است با خروجی متفاوت باشد. در کد نمونه، tickets در ورودی رشته است و پس از parse عدد می‌شود. z.input و z.output این دو سمت را بیان می‌کنند؛ z.infer برای تایپ خروجی کاربرد دارد. تابعی که سفارش را ثبت می‌کند باید خروجی معتبر را دریافت کند، نه داده خام فرم را. این تفکیک کمک می‌کند مرز ورود داده در ساختار کد دیده شود.

همچنین تایپ معتبر را با داده معتبر همیشگی اشتباه نگیرید. اگر شیء بعداً تغییر کند یا داده از منبع دیگری دوباره وارد شود، باید قرارداد همان مرحله رعایت شود. یک بار parse در frontend، اعتماد backend را ایجاد نمی‌کند. مرورگر تحت کنترل کاربر است و درخواست می‌تواند بیرون از فرم ساخته شود. سرور باید ورودی خودش را مستقل بررسی کند و فقط به ظاهر فرم تکیه نکند.

خطا را به فیلد و حرکت بعدی وصل کنید

کاربر به متن «Invalid input» کمک زیادی نمی‌گیرد. پیام باید بگوید کدام مقدار مشکل دارد و چه تغییری لازم است. در راهنمای Customizing Errors، روش تعریف پیام‌ها توضیح داده شده است. متن فارسی کوتاه و مشخص برای محصول بهتر از ترجمه کلمه‌به‌کلمه یک پیام فنی است. برای مثال، «تعداد را بین یک تا پنج وارد کنید» اقدام لازم را نشان می‌دهد.

ساختار خطا هم مهم است. برای فرم ساده می‌توان خطاهای فیلد را جدا نمایش داد؛ برای داده تودرتو، مسیر هر issue اهمیت بیشتری دارد. راهنمای Formatting Errors ابزارهای مختلف را معرفی می‌کند. در رابط واقعی، پیام را به input مرتبط کنید و مقادیر درست قبلی را از بین نبرید. راهنمای طراحی فرم درباره تجربه اصلاح خطا و جریان کار مرتبط است.

قواعد کسب‌وکار و مجوز را جدا نگه دارید

ایمیل با قالب معتبر الزاماً متعلق به کاربر نیست. تعداد مجاز بلیت الزاماً به معنی موجودی کافی نیست. پذیرفته شدن شناسه سفارش هم مالکیت آن را ثابت نمی‌کند. پس از parse، سرور باید قواعد مرتبط با داده واقعی و دسترسی را اعمال کند. اگر این دو سطح را در توضیح تیم مخلوط کنیم، ممکن است یک schema موفق به اشتباه معادل اجازه انجام عملیات تلقی شود.

برای عملیات حساس، نتیجه اعتبارسنجی و نتیجه کسب‌وکار پیام‌های متفاوتی دارند. «تعداد نامعتبر است» با «این تعداد موجود نیست» یک خطا نیست. اولی به شکل درخواست مربوط است و دومی به وضعیت فعلی محصول. نگه داشتن این تفاوت در مدل پاسخ، طراحی رابط و پشتیبانی را ساده‌تر می‌کند. کاربر هم بهتر می‌فهمد باید مقدار را اصلاح کند یا گزینه دیگری انتخاب کند.

Schema مشترک و قرارداد API

اشتراک schema میان client و server می‌تواند از اختلاف قواعد جلوگیری کند، اما باید وابستگی‌ها و اطلاعات اختصاصی سرور را در نظر گرفت. همه منطق backend نباید وارد bundle مرورگر شود. مدل خروجی عمومی نیز ممکن است از مدل داخلی پایگاه داده کوچک‌تر باشد. قرارداد مشترک مفید است وقتی دقیقاً همان داده قابل اشتراک را بیان کند؛ نه وقتی تمام جزئیات ذخیره‌سازی را به مصرف‌کننده بیرونی نشان دهد.

Zod امکان کار با JSON Schema را هم توضیح می‌دهد، اما همه تبدیل‌ها و انواع به هر قالبی قابل نمایش نیستند. راهنمای رسمی JSON Schema محدودیت‌ها را مشخص می‌کند. اگر یک ابزار دیگر قرارداد را می‌خواند، فقط وجود فایل خروجی کافی نیست؛ باید بدانید کدام قواعد در آن منتقل شده‌اند. transformهای اجرایی با یک شرح صرفاً ساختاری یکسان نیستند.

از یک مرز کوچک شروع کنید

برای افزودن Zod لازم نیست تمام پروژه را یک‌باره بازنویسی کنید. یک مرز روشن مثل ورودی فرم یا پاسخ یک API انتخاب کنید. داده خام، داده معتبر و نتیجه خطا را نام‌گذاری کنید و مصرف‌کنندگان را به خروجی parse متصل کنید. اگر schema بیش از حد پیچیده شد، شاید مدل محصول یا مرز مسئولیت‌ها به توضیح بیشتری نیاز دارد. ابزار نباید ابهام قرارداد را پشت کد طولانی پنهان کند.

نسخه dependency را در پروژه مشخص نگه دارید و برای تغییر نسل، یادداشت‌های انتشار Zod 4 را بخوانید. ادعاهای عملکرد نسخه‌ها را هم به نتیجه عمومی همه برنامه‌ها تبدیل نکنید. ارزش عملی برای تیم شما این است که داده نامعتبر در جای مشخص رد شود، پیام مناسب تولید شود و کد بعدی با قرارداد واضح کار کند. این هدف از انتخاب یک نام محبوب مهم‌تر است.

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

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

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

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