چطور دومین میدلور تایپهای TypeScript رو خراب کرد
خلاصهٔ کاملتر
لینل بونت تو وبلاگ Inngest تعریف میکنه موقع مرور ایشوهای باز inngest-js به یه گزارش عجیب رسیده: با یه میدلور همهچیز درسته، ولی تا میدلور دوم اضافه میشه تایپ خروجی step.run به {} فرو میریزه و دسترسی به فیلدها ارور میده. نکتهٔ عجیبتر اینکه کدِ داخل میدلورها هیچ نقشی نداره — دو میدلور کاملاً بیاثر هم همین باگ رو میسازن. یعنی تعداد میدلورهاست که تایپها رو خراب میکنه.
زمینهاش اینه که نتیجهٔ هر step.run سریالایز و ذخیره میشه تا بین retryها memoize بشه؛ پس تابع میتونه Date برگردونه ولی موقع replay رشته برمیگرده. هر میدلور یه ترنسفورم تایپی برای خروجی داره که پیشفرضش Jsonify ـه، و این ترنسفورمها روی هم سوار میشن — دو میدلور یعنی Jsonify. در زمان اجرا سریالایزکردن دادهٔ از قبل سریالایزشده بیخطره، ولی تو سطح تایپ idempotent نیست:
type Widget = { media: { mediaId: string; label?: string }[] };
type Once = Jsonify<Widget>; // { media: { mediaId: string; label?: string }[] }
type Twice = Jsonify<Once>; // { media: JsonifyObject<{}>[] }اگه label?: string رو برداری، Twice سالم درمیآد؛ کل شکست به یه پراپرتی اختیاری بنده. دلیلش اینه که Jsonify برای انداختن کلیدهای غیرقابلسریالایز از ایدیوم رایج «مپ کن، بعد با [keyof T] بخون» استفاده میکنه. مپد تایپهایی که با [Key in keyof T] نوشته میشن مودیفایرها رو نگه میدارن — از جمله ? — و خوندن یه پراپرتی اختیاری تو TypeScript، undefined رو هم همراه خودش برمیگردونه. پس یونیون کلیدها میشه "mediaId" | "label" | undefined.
این یونیون آلوده به Pick داده میشه. Pick قید K extends keyof T داره، ولی به گفتهٔ نویسنده کامپایلر این قید رو فقط همونجایی که کد نوشته شده چک میکنه، نه هر بار که تایپ با آرگومان واقعی باز میشه. سرِ تعریف، T هنوز انتزاعیه و تایپ مشخصی وجود نداره که undefined ازش بیرون بزنه، پس چک پاس میشه.
بعد که T با یه تایپ واقعی پر میشه، کامپایلر تعریفی رو باز میکنه که قبلاً تأییدش کرده و دیگه قید رو دوباره نمیسنجه. keyof T شامل undefined میمونه، محاسبهٔ T[undefined] بهجای ارور به تایپ خطای داخلی TypeScript تبدیل میشه — چیزی شبیه NaN که هر چی لمس کنه به خودش تبدیلش میکنه — و آخر خط Pick بدون کلید معتبر {} تولید میکنه. تایپ حاصل فقط نیمهخرابه: دسترسی به پراپرتی، assignability و hover همه سالم به نظر میرسن، و همین بود که تستهای موجود رو سبز نگه داشت.
نویسنده میگه دو PR از جامعه قبل از او سراغ باگ رفته بودن — یکی ترکیب ترنسفورمها رو دستکاری کرده بود و یکی خود ترنسفورم رو گارد گذاشته بود — ولی هر دو یه لایه عقبتر از ریشه ایستادن و با یه ترنسفورم سفارشی یا از مسیر step.invoke باگ برمیگشت. جالب اینکه فیلترهای همسایه تو type-fest، یعنی FilterDefinedKeys و FilterOptionalKeys، نتیجهشون رو با Exclude تمیز میکنن و فقط FilterJsonableKeys این کارو نمیکرد. فیکس نهایی همونجاییه که undefined وارد میشه:
type FilterJsonableKeys<T extends object> = Exclude<
{
[Key in keyof T]: T[Key] extends NotJsonable ? never : Key;
}[keyof T],
undefined
>;یه تلهٔ آخر هم هست: تست رگرسیون طبیعی یعنی IsEqual روی کدِ خراب هم پاس میشه، چون IsEqual دو الیاس رو در حالت معوق مقایسه میکنه و خرابی فقط وقتی بیرون میزنه که ارزیابی مجبور بشه. تستی که واقعاً fail میده همون مدل بهظاهر پیشپاافتادهست که از راه دسترسی به پراپرتی، تایپ رو مجبور به resolve شدن میکنه. نویسنده اضافه میکنه که تو ریویو نسخهٔ تمیزتری هم پیشنهاد شده: بهجای خوندن کلیدها به شکل یونیون مقادیر و بعد پاککردن undefined، کلیدها رو سر جاشون فیلتر کنی تا کل این تمیزکاری بیمورد بشه.
نکات کلیدی:
- تو ایدیوم فیلتر کلید با [keyof T]، پراپرتیهای اختیاری یه undefined وارد نتیجه میکنن؛ حتی وقتی بیاهمیت به نظر میرسه Exclude ـش کن
- قید جنریک همونجایی که نوشته میشه سنجیده میشه، نه وقتی با تایپ واقعی باز میشه؛ یه تایپ بدشکل میتونه بیصدا ساخته و دور دنیا بچرخه
- تایپ خطای داخلی TypeScript هر چی لمس کنه رو میبلعه و ممکنه به شکل unknown یا {} به دستت برسه
- اگه یه ترنسفورم تایپی با خودش ترکیبشدنیه، خودِ ترکیب رو تست کن: f(f(x)) باید با f(x) برابر باشه
- تستهای تایپ رو از مسیر دسترسی به پراپرتی بنویس، نه فقط IsEqual روی الیاسها




