TS: הגדרת interface לאובייקט המכיל מופעים של קלאס גנרי
-
לאחר העזרה של @רפאל התותח כאן, בסופו של דבר ביצעתי את האימפלמנטציה של זה בצורה גנרית לאחר שעשיתי refactoring לקוד והרכבתי אותו בצורה יותר טובה.
כעת השאלה שלי היא כזאת:
יש בידי מחלקה גנרית בשם
Instruction
, שמקבלת Type גנרי שיכול להיות אוUnaryOperands
אוBinaryOperands
- מתוך האינטרפייסים הבאים:interface UnaryOperands { dst: number; } interface BinaryOperands extends UnaryOperands { src: number; }
קיימת מחלקה נוספת הנקראת בשם
Processor
, שמחזיקה property שנקראinstructions
המחזיק אובייקט של מופעים של המחלקה הגנריתInstruction
.שאלתי היא - מהי הדרך המומלצת להגדיר את ה-Type של האובייקט הנ"ל.
חשבתי בתחילה להשתמש ב-Union type ככה:
interface Instructions { [index: string]: Instruction<UnaryOperands | BinaryOperands>; }
אך התברר לי שזאת דרך בעייתית, לאחר שכתבתי מתודה במחלקה
Processor
שמקבלת instruction מתוך האובייקט instructions שב-Processor. את המתודה יישמתי כמו הדרכתו של @רפאל בצורה של Overloading כך:private perform (instruction: Instruction<{ dst: number }>): void; private perform (instruction: Instruction<{ dst: number, src: number }): void; private perform (instruction: Instruction<{dst: number, src?: number }): void { } // implementation
אבל כשהשתמשתי במתודה הזאת והעברתי לה instruction, מיד TS הקפיץ לי את השגיאה הבאה:
Argument of type 'Instruction<UnaryOperands | BinaryOperands>' is not assignable to parameter of type 'Instruction<{ dst: number; src: number; }>'. Type 'UnaryOperands | BinaryOperands' is not assignable to type '{ dst: number; src: number; }'. Property 'src' is missing in type 'UnaryOperands' but required in type '{ dst: number; src: number; }'
המסקנה שהגעתי אליה היא (ייתכן שאני טועה) - שההגדרה שביצעתי ל-Type של
Instructions
היא לא נכונה, וצריכה להיות דרך יותר טובה להגדיר את ה-Type שלו.אשמח לעזרתכם.
-
נאלצתי לנחש חלק מהקוד. השורות דלהלן מתקמפלות כראוי.
type OptionalExceptFor<T, TRequired extends keyof T> = Partial<T> & Pick<T, TRequired> interface UnaryOperands { dst: number; } interface BinaryOperands extends UnaryOperands { src: number; } interface Instruction<T extends UnaryOperands> { operands: T; } function perform(instruction: Instruction<UnaryOperands>): void function perform(instruction: Instruction<BinaryOperands>): void function perform(instruction: Instruction<OptionalExceptFor<BinaryOperands, 'dst'>>): void { } perform({ operands: { dst: 45 } });
-
@מוטי-אורן אתה יכול להשתמש בUtility Type דלהלן כדי להפוך את כל השדות החסרים בBase לOptional:
/** * Construct a type with the properties of TOptional in which all keys not included in TRequired are marked as optional */ type MarkBaseMissingPropertiesAsOptional<TRequired, TOptional extends TRequired> = Partial<Exclude<TOptional, keyof TRequired>> & Pick<TOptional, keyof TRequired>;
שימוש
MarkBaseMissingPropertiesAsOptional<UnaryOperands, BinaryOperands>
תוצאה
{ dst: string; src?: string; }
-
@מוטי-אורן רציתי להבין את הבעיה והפתרון, אבל נתקעתי כי לא הצלחתי לשחזר את הבעיה
הנה playground שלא משחזר את הבעיה
האם תוכל ליצור אחד שכן משחזר? -
@yossiz האמת שבבואי כעת לשחזר את הבעיה, אני קולט את דברי @רפאל שבאמת לא פרטתי מספיק את הקוד שהשתמשתי בו. השמטתי בשאלתי את הפרמטר השני שפונקציית ה-perform מקבלת, משום שסברתי שאינה קשורה לבעיה המדוברת.. כעת מסתבר לי שזוהי סיבת הבעיה.
להלן הקישור ל-playground המשחזר את הבעיה.
-
@מוטי-אורן נראה לי שכעת זכיתי להבין את הבעיה, עכשיו אני מתקשה בהבנת הפתרון...
בנוסח שלי, הבעיה היא כזאת:
הגדרת שתי "חתימות" לפונקציה שלך דרך function overloading. עכשיו הקריאה חייבת להתאים לאחת מהם, והיא לא מתאימה לשום אחד. כי הראשון אומנם מקבל בארגומנט הראשון<Instruction<UnaryOperands
(וממילא גם כל דבר שיורש ממנו) אבל בשני הוא מצפה רק ל-dst
. והשני מצפה לקבל<Instruction<BinaryOperands
ו-Instruction<UnaryOperands | BinaryOperands>
לא מתאים לו.לא הבנתי איך הפתרון פותר כלום.
ובנסיונות שלי זה באמת לא פותר את הבעיה.מה שכן פותר הוא להוריד את החתימות של ה-overloads ולהשאיר רק את החתימה של ה-implementation, אבל אז ירד לך חלק מה-type safety
-
להוסיף על דבריו הנכונים של @yossiz:
נתונים שתי חתימות:
perform(instruction: Instruction<UnaryOperands>): void; perform(instruction: Instruction<BinaryOperands>): void;
החתימה הנכונה תיקבע לפי זהות הפרמטר הניתן לInstruction (הסוג של T), כיוון שהסוג שמועבר בדוגמה לInstruction הוא מסוג Union פתוח של שני הסוגים (
UnaryOperands | BinaryOperands
) המהדר אינו יכול לקבוע את זהות החתימה הנכונה משום שבזמן הכתיבה לא ניתן לדעת את הנתון הזה (זהות הסוג הספיציפי בתוך הUnion).די ברור מדוע הקוד דלהלן אינו תקין:
interface A { field1: number; } interface B { field2: number; } function perform(param: A): void; function perform(param: B): void; function perform(param: A | B): void { } let param: A | B; perform(param);
המהדר יעבור חתימה אחר חתימה בנסיון למצוא את החתימה המתאימה, בסיום אם נכשל יספק אינדיקציה עבור כל חתימה מדוע אינה מתאימה:
No overload matches this call. Overload 1 of 2, '(param: A): void', gave the following error. Argument of type 'A | B' is not assignable to parameter of type 'A'. Property 'field1' is missing in type 'B' but required in type 'A'. Overload 2 of 2, '(param: B): void', gave the following error. Argument of type 'A | B' is not assignable to parameter of type 'B'. Property 'field2' is missing in type 'A' but required in type 'B'.ts(2769)
לסיכום
הפיצרים Overloading וUnion types קצת בעייתיים לשימוש בו זמני, משום שעבור השימוש בOverloading דרוש סוג קונקרטי (אלא אם כן החתימה בעצמה מצהירה על Union). -
@רפאל אמר בTS: הגדרת interface לאובייקט המכיל מופעים של קלאס גנרי:
די ברור מדוע הקוד דלהלן אינו תקין:
interface A { field1: number; } interface B { field2: number; } function perform(param: A): void; function perform(param: B): void; function perform(param: A | B): void { } let param: A | B; perform(param);
כן. פה זה יותר ברור.
במקרה של @מוטי-אורן לפי איך שהוא הציג את השאלה בהתחלה זה באמת היה אמור לעבוד. כי אצלו ה-interface B
יורש מ-interface A
ואם כן ה-union שלהם מתאים באמת לחתימה הראשונה כי ה-B
הוא גםA
. לכן התבלבלתי.השאלה שלו הציגה מקרה כזו:
interface A { field1: number; } interface B extends A { field2: number; } function perform(param: A): void; function perform(param: B): void; function perform(param: A | B): void { } let param = { field1: 1 } as A | B; perform(param);
אגב, מעניין לראות מתוך דוגמאות אלו איזה מהם תקין:
interface A { field1: number; } interface B { field2: number; } function perform(param: A): void; function perform(param: B): void; function perform(param: A | B): void { } function getAOrB() : A | B { return { field1: 1 } } const param1: A | B = { field1: 1 } const param2 = { field1: 1 } as A | B const param3: A | B = getAOrB() const param4: A | B = { field1: 1, field2: 2 } perform(param1) perform(param2) perform(param3) perform(param4)
הראשון תקין. למה? איפה מתועד התנהגות זו?
-
@yossiz אמר בTS: הגדרת interface לאובייקט המכיל מופעים של קלאס גנרי:
הראשון תקין. למה? איפה מתועד התנהגות זו?
נראה לי שהבנתי, יש שתי רמות של טייפים ב-TS, יש את ה-declared type וה-observed type. ה-declared type משפיע על ה-assignability וה-observed type משפיע על השימוש בפועל בערך.
השמה של ערך לתוך משתנה יכול להשפיע על ה-observed type על ידי מנגנון שנקרא type narrowing.
דוגמה:// declared type let x: string | number // observed type becomes number (type narrowing by assignment) x = 1.0 // legal to use as number x.toFixed() // still fine to reassign to string x = "Hi" // observed type is now string (again type narrowing by assignment) x.charAt(1); // coerced to string | number x = x as string | number // illegal! // Property 'charAt' does not exist on type 'string | number'. // Property 'charAt' does not exist on type 'number'.(2339) x.charAt(1)
מקור:
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#assignmentsלכן בשורה זו:
const param1: A | B = { field1: 1 }
למרות שה-declared type הוא
A | B
אבל יש מיד type narrowing על ידי ההשמה.