TypeScript المتقدم مع مثال حقيقي (الجزء 2)

نشرت: 2020-11-20

حان الوقت لمواصلة (ونأمل أن تنتهي) البرنامج التعليمي TypeScript الخاص بنا. إذا فاتتك المنشورات السابقة التي كتبناها عن TypeScript ، فإليك: مقدمتنا الأولية إلى TypeScript ، والجزء الأول من هذا البرنامج التعليمي حيث أشرح مثال JavaScript الذي نعمل معه والخطوات التي اتخذناها لتحسينه جزئيًا .

اليوم سننهي مثالنا بإكمال كل ما لا يزال مفقودًا. على وجه التحديد ، سنرى أولاً كيفية إنشاء أنواع هي إصدارات جزئية لأنواع أخرى موجودة. سنرى بعد ذلك كيفية كتابة إجراءات متجر Redux بشكل صحيح باستخدام اتحادات النوع ، وسنناقش مزايا عرض اتحادات النوع. وأخيرًا ، سأوضح لك كيفية إنشاء وظيفة متعددة الأشكال يعتمد نوع إرجاعها على حججها.

مراجعة موجزة لما قمنا به حتى الآن ...

في الجزء الأول من البرنامج التعليمي ، استخدمنا (جزء من) متجر Redux أخذناه من Nelio Content كمثال عملي. بدأ كل شيء كرمز JavaScript عادي والذي كان لا بد من تحسينه عن طريق إضافة أنواع محددة تجعله أكثر قوة ووضوحًا. وهكذا ، على سبيل المثال ، حددنا الأنواع التالية:

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; };

مما ساعدنا على فهم نوع المعلومات التي يعمل بها متجرنا بنظرة واحدة. في هذه الحالة بالذات ، على سبيل المثال ، يمكننا أن نرى أن حالة تطبيقنا تخزن شيئين: قائمة posts (التي قمنا بفهرستها من خلال PostId الخاصة بهم) وبنية تسمى days التي تعرض ، في يوم معين ، قائمة من معرفات آخر. يمكننا أيضًا رؤية السمات (وأنواعها المحددة) التي سنجدها في كائن Post .

بمجرد تحديد هذه الأنواع ، قمنا بتحرير جميع وظائف مثالنا لاستخدامها. حولت هذه المهمة البسيطة تواقيع وظائف JavaScript غير الشفافة:

 // Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }

لتوقيعات دالة TypeScript التي لا تحتاج إلى شرح:

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... }

تعد وظيفة getPostsInDay مثالاً جيدًا جدًا على مدى قيام TypeScript بتحسين جودة التعليمات البرمجية الخاصة بك. إذا نظرت إلى نظير JavaScript ، فأنت لا تعرف حقًا ما الذي ستعود إليه هذه الوظيفة. بالتأكيد ، قد يشير اسمها إلى نوع النتيجة (هل هي قائمة منشورات ، ربما؟) ، ولكن يجب أن تنظر إلى الكود المصدري للوظيفة (وربما الإجراءات والمخفضات أيضًا) للتأكد (إنها في الواقع قائمة من معرّفات النشر). يمكن للمرء تحسين هذا الموقف عن طريق تسمية الأشياء بشكل أفضل ( getIdsOfPostsInDay ، على سبيل المثال) ، ولكن لا يوجد شيء مثل الأنواع الملموسة لإزالة أي شك: PostId[] .

لذا ، بعد أن أصبحت على دراية بالوضع الحالي ، حان الوقت للمضي قدمًا وإصلاح كل شيء تخطينا الأسبوع الماضي. على وجه التحديد ، نعلم أننا بحاجة إلى كتابة attributes وظيفة updatePost ونحتاج إلى تحديد الأنواع التي ستشتمل عليها إجراءاتنا (لاحظ أنه في reducer ، تكون سمة action الآن من النوع any ).

كيفية كتابة كائن سماته هي مجموعة فرعية من كائن آخر

لنبدأ الإحماء بالبدء بشيء بسيط. تنشئ وظيفة updatePost إجراءً يشير إلى نيتنا في تحديث سمات معينة لمعرف منشور معين. إليك كيف تبدو:

 function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }

وإليك كيفية استخدام الإجراء بواسطة المخفض لتحديث المنشور في متجرنا:

 function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }

كما ترى ، يبحث المخفض في المنشور في المتجر ، وإذا كان موجودًا ، فإنه يقوم بتحديث سماته عن طريق الكتابة فوقها باستخدام العناصر المضمنة في الإجراء.

ولكن ما هي بالضبط attributes العمل؟ حسنًا ، من الواضح أنهم شيء يشبه Post ، حيث من المفترض أن يستبدلوا السمات التي قد نجدها في المنشور:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };

ولكن إذا حاولنا استخدام هذا ، فسنرى أنه لا يعمل:

 const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, };

لأننا لا نريد أن تكون attributes عبارة عن Post بحد ذاته ؛ نريد أن تكون مجموعة فرعية من سمات Post (أي نريد تحديد سمات كائن Post الذي سنقوم بالكتابة فوقه).

لحل هذه المشكلة ، ما عليك سوى استخدام نوع الأداة Partial :

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };

وهذا كل شيء! أو هو؟

سمات التصفية صراحة

المقتطف السابق لا يزال معيبًا ، لأنه من الممكن الحصول على بعض أخطاء وقت التشغيل التي لا يتحقق مترجم TypeScript فيها. إليك السبب: الإجراء الذي يشير إلى تحديث المنشور هو وسيطتان ، معرف المنشور ومجموعة السمات التي نريد تحديثها. بمجرد أن يكون الإجراء جاهزًا ، يكون المخفض مسؤولاً عن الكتابة فوق المنشور الحالي بالقيم الجديدة:

 const post = { ...state.posts[ action.postId ], ...action.attributes, };

وهذا هو بالضبط الجزء الخاطئ في الكود لدينا ؛ من المحتمل أن تحتوي سمة postId على معرف pots x وأن سمة id في attributes لها معرف منشور مختلف y :

 const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, };

من الواضح أن هذا إجراء صالح ، وبالتالي لا يتسبب TypeScript في حدوث أي أخطاء ، لكننا نعلم أنه لا ينبغي أن يكون كذلك. يجب أن يكون لسمة id في attributes (إن وجدت) postId نفس القيمة ، وإلا فسيكون لدينا إجراء غير متماسك. نوع الإجراء الخاص بنا غير دقيق لأنه يتيح لنا تحديد موقف يجب أن يكون مستحيلاً ... فكيف يمكننا إصلاح ذلك؟ بسهولة تامة: ما عليك سوى تغيير هذا النوع حتى يصبح هذا السيناريو الذي يجب أن يكون مستحيلًا في الواقع مستحيلًا.

الحل الأول الذي فكرت فيه هو ما يلي: إزالة سمة postId من الإجراء وإضافة المعرف في attributes السمات:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; }

بعد ذلك ، قم بتحديث المخفض الخاص بك بحيث يستخدم action.attributes.id بدلاً من action.postId للعثور على المنشور الحالي والكتابة فوقه.

للأسف ، هذا الحل ليس مثاليًا ، لأن attributes هي "مشاركة جزئية" ، هل تتذكر؟ وهذا يعني ، من الناحية النظرية ، أن سمة id قد تكون أو لا تكون في كائن attributes . بالتأكيد ، نعلم أنه سيكون موجودًا ، لأننا نحن من نولِّد الإجراء ... لكن أنواعنا لا تزال غير دقيقة. إذا قام شخص ما في المستقبل بتعديل وظيفة updatePost ولم يتأكد من أن attributes تتضمن postId ، فسيكون الإجراء الناتج صالحًا وفقًا لـ TypeScript لكن الكود الخاص بنا لن يعمل:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

لذا ، إذا أردنا أن تحمينا TypeScript ، فيجب أن نكون دقيقين قدر الإمكان عند تحديد الأنواع والتأكد من أنها تجعل الحالات المستحيلة مستحيلة. بالنظر إلى كل هذا ، لدينا خياران فقط متاحان:

  1. إذا كانت لدينا سمة postId قيد التشغيل (كما فعلنا في البداية) ، فيجب ألا يحتوي كائن attributes على سمة id .
  2. من ناحية أخرى ، إذا لم يكن للإجراء سمة postId ، فيجب أن تحتوي attributes على سمة id .

يمكن تحديد الحل الأول بسهولة باستخدام نوع أداة مساعدة آخر ، Omit ، والذي يسمح لنا بإنشاء نوع جديد عن طريق إزالة السمات من نوع موجود:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };

الذي يعمل كما هو متوقع:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, };

بالنسبة للخيار الثاني ، يتعين علينا أن نضيف صراحة سمة id أعلى النوع Partial<Post> الذي حددناه:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };

والذي ، مرة أخرى ، يعطينا النتيجة المتوقعة:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

أنواع الاتحاد

في القسم السابق ، رأينا بالفعل كيفية كتابة أحد الإجراءين اللذين يستخدمهما متجرنا. لنفعل الشيء نفسه مع الإجراء الثاني. معرفة أن receiveNewPost يبدو كالتالي:

 function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }

يمكن تحديد نوعه على النحو التالي:

 type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };

حق سهل؟

الآن دعنا نلقي نظرة على action : إنه يأخذ state وإجراء (لا نعرف نوعه بعد) وينتج State جديدة:

 function reducer( state: State, action: any ): State { ... }

يحتوي متجرنا على نوعين مختلفين من الإجراءات: UpdatePostAction و ReceiveNewPostAction . إذن ما هو نوع حجة action ؟ واحد أو الآخر ، أليس كذلك؟ عندما يمكن للمتغير أن يقبل أكثر من نوع A و B و C وما إلى ذلك ، فإن نوعه يكون اتحادًا للأنواع. أي أن نوعه يمكن أن يكون A أو B أو C وهكذا. أنواع الاتحاد هي نوع يمكن أن تكون قيمه من أي من الأنواع المحددة في هذا الاتحاد.

إليك كيفية تعريف نوع Action الخاص بنا على أنه نوع اتحاد:

 type Action = UpdatePostAction | ReceiveNewPostAction;

يوضح المقتطف السابق ببساطة أن Action يمكن أن يكون إما مثيلًا لنوع UpdatePostAction أو مثيل من النوع ReceiveNewPostAction .

إذا استخدمنا الآن Action في مخفضنا:

 function reducer( state: State, action: Action ): State { ... }

يمكننا أن نرى كيف يعمل هذا الإصدار الجديد من الكود ، والمكتوب جيدًا ، بسلاسة.

كيف تقضي أنواع النقابات على حالات التخلف عن السداد

قد تقول "انتظر لحظة" ، "الرابط السابق لا يعمل بسلاسة ، المترجم يتسبب في حدوث خطأ!" في الواقع ، وفقًا لـ TypeScript ، يحتوي المخفض الخاص بنا على رمز لا يمكن الوصول إليه:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }

انتظر ماذا؟ اسمحوا لي أن أشرح ما يحدث هنا ...

نوع اتحاد Action الذي أنشأناه هو في الواقع نوع نقابي تمييزي. نوع الاتحاد المميز هو نوع اتحاد تشترك فيه جميع أنواعه في سمة مشتركة يمكن استخدام قيمتها للتمييز بين نوع واحد عن الآخر.

في حالتنا ، يكون لنوعي Action سمة type قيمها RECEIVE_NEW_POST لـ ReceiveNewPostAction و UPDATE_POST لـ UpdatePostAction . نظرًا لأننا نعلم أن Action هو ، بالضرورة ، مثيل لإجراء واحد أو آخر ، فإن فرعي switch لدينا يغطيان جميع الاحتمالات: إما action.type هو RECEIVE_NEW_POST أو UPDATE_POST . لذلك ، فإن return النهائي زائد ولن يكون من الممكن الوصول إليه.

لنفترض إذن أننا أزلنا هذه return لإصلاح هذا الخطأ. هل ربحنا أي شيء بخلاف إزالة التعليمات البرمجية غير الضرورية؟ الجواب نعم. إذا أضفنا الآن نوعًا جديدًا من الإجراءات في التعليمات البرمجية الخاصة بنا:

 type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... };

فجأة ، لن يغطي بيان switch في مخفضنا جميع السيناريوهات الممكنة:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit }

هذا يعني أن المخفض قد يُرجع ضمنيًا قيمة undefined إذا استدعيناها باستخدام إجراء من النوع NEW_FEATURE ، وهذا شيء لا يتطابق مع توقيع الوظيفة. بسبب عدم التطابق هذا ، تشتكي TypeScript وتتيح لنا معرفة أننا نفقد فرعًا جديدًا للتعامل مع نوع الإجراء الجديد هذا.

وظائف متعددة الأشكال مع أنواع إرجاع متغيرة

إذا كنت قد وصلت إلى هذا الحد ، فتهانينا: لقد تعلمت كل ما تحتاج إلى القيام به لتحسين شفرة المصدر لتطبيقات JavaScript باستخدام TypeScript. وكمكافأة ، سأشارككم "مشكلة" صادفتها قبل أيام قليلة وحلها. لماذا ا؟ لأن TypeScript هو عالم معقد ورائع وأريد أن أوضح لكم مدى صحة هذا.

في بداية هذه المغامرة بأكملها ، رأينا أحد المحددات التي لدينا هي getPostsInDay وكيف أن نوع الإرجاع هو قائمة بمعرفات المنشور:

 function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }

على الرغم من أن الاسم يوحي بأنه قد يعرض قائمة بالمشاركات. أنت تتساءل لماذا استخدمت مثل هذا الاسم المضلل؟ حسنًا ، تخيل السيناريو التالي: افترض أنك تريد أن تكون هذه الوظيفة قادرة إما على إرجاع قائمة بمعرفات المنشورات أو إرجاع قائمة بالمشاركات الفعلية ، اعتمادًا على قيمة إحدى وسائطها. شيء من هذا القبيل:

 const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );

هل يمكننا فعل ذلك في TypeScript؟ بالطبع نقوم به! وإلا فلماذا سأطرح هذا بطريقة أخرى؟ كل ما يتعين علينا القيام به هو تحديد وظيفة متعددة الأشكال التي تعتمد نتيجتها على معلمات الإدخال.

إذن ، الفكرة هي أننا نريد نسختين مختلفتين من نفس الوظيفة. يجب على المرء إرجاع قائمة PostId إذا كانت إحدى السمات هي معرف string . يجب أن يعرض الآخر قائمة Post إذا كانت نفس السمة هي string كلها .

دعونا ننشئ كلاهما:

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }

حق سهل؟ خاطئ - ظلم - يظلم! هذا لا يعمل. وفقًا لـ TypeScript ، لدينا "تنفيذ وظيفة مكررة".

حسنًا ، دعنا نجرب شيئًا مختلفًا ، إذن. دعنا ندمج التعريفين السابقين في وظيفة واحدة:

 function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }

هل هذا يتصرف كما نريد؟ أخشى أنه لا ...

إليك ما يخبرنا به توقيع الوظيفة هذا: " getPostsInDay هي دالة تأخذ وسيطين ، state mode يمكن أن تكون قيمها معرّف أو كلها ؛ سيكون نوع الإرجاع إما قائمة PostId أو قائمة منشورات Post . بمعنى آخر ، لا يحدد تعريف الوظيفة السابق في أي مكان توجد فيه علاقة بين القيمة المعطاة لوسيطة mode ونوع إرجاع الوظيفة. ولذا فإن الكود مثل هذا:

 const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

صالح ولا يتصرف بالطريقة التي نريدها.

حسنًا ، المحاولة الأخيرة. ماذا لو مزجنا حدسنا الأولي ، حيث نصف التوقيعات الوظيفية الملموسة ، مع تنفيذ واحد صالح؟

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); }

يحتوي المقتطف السابق على تنفيذ صالح للوظيفة يعمل ، ولكنه يحدد توقيعين إضافيين للوظيفة يربطان القيم الملموسة في mode بنوع إرجاع الوظيفة.

باستخدام هذا الأسلوب ، هذا الرمز صالح:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

وهذا لا:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );

الاستنتاجات

في هذه السلسلة من المنشورات ، رأينا ماهية TypeScript وكيف يمكننا تطبيقها في مشاريعنا. تساعدنا الأنواع على توثيق الكود بشكل أفضل من خلال توفير السياق الدلالي. علاوة على ذلك ، تضيف الأنواع أيضًا طبقة إضافية من الأمان ، نظرًا لأن برنامج التحويل البرمجي TypeScript يهتم بالتحقق من أن الكود الخاص بنا يناسب معًا بشكل صحيح ، تمامًا مثل Legos.

في هذه المرحلة ، لديك بالفعل جميع الأدوات اللازمة للارتقاء بجودة عملك إلى المستوى التالي. حظا سعيدا في هذه المغامرة الجديدة!

صورة مميزة بواسطة مايك كينيلي على Unsplash.