شما تایپی مینویسید که در اشیاء تو در تو پیمایش میکند و مسیرهای جدا شده با نقطه را برای تکمیل خودکار (autocomplete) میسازد. این تایپ روی یک شیء تست کوچک بهخوبی کار میکند. اما وقتی آن را روی یک Payload واقعی از یک API قرار میدهید، ویرایشگر (editor) هنگ میکند. در نهایت، تایپاسکریپت خطای TS2589: *Type instantiation is excessively deep and possibly infinite.* را برمیگرداند.
این پیام به این معنا نیست که کد شما حاوی یک حلقه بینهایت به معنای سنتی است؛ بلکه به این معناست که کامپایلر تسلیم شده است. تایپی که از آن خواستهاید محاسبه شود، یا واقعاً نامحدود بوده و یا محدود اما آنقدر بزرگ بوده که ارزیابی آن محدودیتهای داخلی تایپاسکریپت را تخلیه میکند. وقتی این اتفاق میافتد، کامپایلر قبل از اینکه IDE شما را از کار بیندازد، متوقف میشود.
چه زمانی TS2589 ظاهر میشود
تایپهای بازگشتی (Recursive types) رایجترین مقصر هستند. تایپاسکریپت تایپها را بهصورت حریصانه (eagerly) ارزیابی میکند و اگر یک تایپ کمکی (utility type) مدام خودش را فراخوانی کند — بهویژه از طریق منطق شرطی — پشته محاسباتی (computation stack) بهسرعت رشد میکند. شما معمولاً در چند سناریوی خاص با این دیوار برخورد میکنید:
- تایپهای شرطی بازگشتی (Recursive conditional types) که بهطور مکرر یک tuple، object یا string template را تا رسیدن به حالت پایه (base case) تجزیه (destructure) میکنند.
- مولدهای مسیر اشیاء با تو در تو عمیق (Deeply nested object path generators)، که ساختارهایی مانند
{ user: { address: { street: string } } }را به یونینهایی از رشتههای لیترال مانند"user" | "user.address" | "user.address.street"تبدیل میکنند. - تایپهای Template literal که رشتهها را کاراکتر به کاراکتر یا توکن به توکن تجزیه میکنند.
- تایپهای نگاشتشده (Mapped types) که روی اشیائی با دهها کلید و چندین سطح تکرار میشوند.
- تایپهای شرطی (Conditional types) که روی یونینهای بزرگ توزیع میشوند و بهطور بیصدا حجم کار را برای هر عضو ضرب در میکنند.
مثال مسیرهای تو در تو بهویژه وسوسهانگیز است. کتابخانههای فرم و ابزارهای مدیریت وضعیت (state-management) عاشق ارائه مسیرهای تایپشده هستند تا شما برای نام فیلدها قابلیت autocomplete داشته باشید. در یک شیء کمعمق، تولید هر مسیر نقطهای معتبر به عنوان یک یونین رشتهای کار سادهای است. اما در یک شیء عمیق یا پهن، آن یونین منفجر میشود. تایپاسکریپت باید تمام حالات ممکن را بهطور همزمان در حافظه کاری نگه دارد. در مقداری از عمق، کامپایلر متوجه میشود که حجم کار از بودجهی محاسباتیاش فراتر رفته و ترمز اضطراری را میکشد.
راه حل اول: افزودن یک محدودیت عمق مشخص
مستقیمترین راه برای حل TS2589 این است که وانمود نکنید تایپ شما میتواند تا ابد بازگشت (recurse) کند. یک شمارنده عمق معرفی کنید که مانند یک قطعکننده مدار (circuit breaker) عمل کند.
در عمل، این به معنای افزودن یک پارامتر جنریک عددی است — که اغلب به صورت یک tuple نمایش داده میشود که طول آن به پایین شمارش میکند — و با هر بار بازگشت تایپ، کاهش مییابد. وقتی شمارنده به صفر رسید، تایپ به جای ادامه جستجو، یک مقدار جایگزین کلی مانند string را برمیگرداند. کاربران همچنان برای چهار یا پنج سطح اول، autocomplete دقیقی دریافت میکنند که اکثریت قریب به اتفاق اشیاء در دنیای واقعی را پوشش میدهد. فراتر از آن، کامپایلر صرفاً تایپ را گسترش داده و از آن عبور میکند.
این رویکرد باعث نمیشود که تایپ کمکی شما از نظر معنایی دقت کمتری داشته باشد؛ بلکه آن را محدود (bounded) میکند. یک سیستم تایپ که باعث کرش کردن کامپایلر میشود، مفیدتر از سیستمی نیست که پس از یک عمق منطقی، با ملایمت عقبنشینی میکند.
راه حل دوم: اعتبارسنجی یک مسیر در هر بار
اگر تولید تمام مسیرهای ممکن از پیش بسیار هزینهبر است، قرارداد را تغییر دهید. به جای تولید یک یونین عظیم از تمام رشتههای معتبر، تایپی بنویسید که بررسی کند آیا یک رشته خاص یک مسیر معتبر است یا خیر.
تفاوت بین ایجاد یک دیکشنری از تمام کلمات انگلیسی با بررسی اینکه آیا یک کلمه خاص درست نوشته شده است یا خیر را در نظر بگیرید. اولی یک ساختار داده عظیم است؛ دومی یک اسکن سبک. در اصطلاحات تایپاسکریپت، به جای خروجی دادن یک ابزار Paths<T> که مقادیری مثل "user.address.street" | "user.settings.theme" | ... تولید میکند، چیزی شبیه به IsValidPath<T, "user.address.street"> را خروجی میدهید. کامپایلر فقط مسیری را که واقعاً ارسال کردهاید ارزیابی میکند.
این تغییر، نحوه طراحی APIهای شما را تغییر میدهد. امضای توابع شما ممکن است یک رشته را بپذیرد و سپس از یک محدودیت جنریک (generic constraint) برای تأیید آن نسبت به ساختار شیء استفاده کند. اگر توسعهدهنده یک مسیر اشتباه تایپ کند، IDE همچنان شکایت میکند، اما کامپایلر هرگز مجبور نیست در طول فرآیند بررسی تایپ، مجموعه کامل مسیرهای قانونی را تجسم (materialize) کند. برای اشیاء بزرگ، تفاوت عملکرد بسیار چشمگیر است.
تاکتیکهای سریع برای ادامه کار
فراتر از دو راه حل ساختاری، چند عادت کوچکتر میتواند مانع از عبور تایپهای بازگشتی از خط قرمز شود:
پارامترهای نوع را در tupleها قرار دهید تا از توزیع (distribution) جلوگیری کنید. یک پارامتر نوع بدون پوشش در یک شرط، مانند
T extends Foo ? Bar : Baz، زمانی کهTیک union باشد، بررسی را روی تکتک اعضا توزیع میکند. اگر آن union پنجاه عضو داشته باشد، TypeScript پنجاه بار نمونهسازی (instantiation) مجزا انجام میدهد. نوشتن[T] extends [Foo] ? Bar : Bazشرط را تنها یک بار برای کل union ارزیابی میکند. هر زمان که واقعاً نیاز ندارید که تایپ روی تکتک اعضای union بهصورت جداگانه پیمایش (map) شود، از این روش استفاده کنید.هنگام عیبیابی، ورودیهای خود را کوچک کنید. وقتی خطای TS2589 ظاهر میشود، نوع شیء اصلی (production object type) خود را با یک stub بسیار کوچک شامل دو ویژگی (property) و یک سطح از تو در تو بودن (nesting) جایگزین کنید. اگر خطا برطرف شد، تایید کردهاید که مشکل از عمق یا تعداد اعضا (cardinality) است، نه یک اشتباه سینتکسی. این کار شما را از بازنویسی منطقی که از نظر ساختاری کاملاً درست بود، نجات میدهد.
تایپهای API عمومی را منعطفتر کنید. در محیط داخلی، ممکن است به دقت بسیار بالا نیاز داشته باشید. اما در محیط خارجی، گاهی اوقات هزینه رسیدن به کمال بیش از سود حاصل از آن است. اگر یک تایپ autocomplete کمی گستردهتر، از دو ثانیه تأخیر در ادیتور جلوگیری کند، معمولاً این معامله ارزشش را دارد. میتوانید این تایپ منعطفتر را با یک validator در زمان runtime ترکیب کنید تا مسیرهای اشتباه را در مرحله تست شناسایی کنید.
چرا TypeScript این مرز را اعمال میکند
TypeScript نمیتواند مسئله توقف (halting problem) را حل کند. این زبان نمیداند که آیا تایپ بازگشتی (recursive type) شما در نهایت متوقف میشود یا تا ابد در یک حلقه میچرخد. به جای ریسک کردن برای ایجاد یک حلقه بینهایت در داخل کامپایلر، یک حدِ قطع محافظهکارانه را اعمال میکند. گاهی اوقات این حدِ قطع، تایپی را شامل میشود که اگر زمان کافی داشت، حتماً به پایان میرسید. TS2589 در واقع اعتراف کامپایلر به این است که ترجیح میدهد با احتیاط عمل کند تا اینکه دچار مشکل شود.
رعایت این محدودیت بخشی از نوشتن تایپهای سطح تولید (production-grade) است. تعریف یک تایپ، کدی است که در کامپایلر اجرا میشود و کدهای سنگین پیامدهای واقعی دارند. کند بودن autocomplete همانقدر به سرعت توسعهدهنده آسیب میزند که کند بودن کد در زمان runtime به تجربه کاربری آسیب میزند.
نکته اصلی
TS2589 نشانهای از این نیست که شما برنامهنویس ضعیفی در سیستم تایپ هستید؛ بلکه نشانهای است از اینکه تایپ شما در یک لحظه دارد کار بیش از حدی انجام میدهد. بازگشت (recursion) خود را محدود کنید، اعتبارسنجی را با تأخیر (lazily) انجام دهید و در برابر توزیع (distribution) غیرضروری محافظت کنید. هدف از تایپهای پیشرفته، اثبات هر حقیقت ممکنی در زمان کامپایل نیست؛ بلکه هدف، ارائه ابزارهای سریع و قابل اعتماد به تیم شماست. تایپی که در چند میلیثانیه کامپایل میشود و ۹۵ درصد موارد را پوشش میدهد، بسیار ارزشمندتر از تایپی است که از نظر تئوری بینقص است اما باعث از کار افتادن language server میشود.
