javascript - איך אני יכול לגרום ל-getter ו-setter שנוצרים בצורה דינמית להיות ידועים למתקין TypeScript?


הקוד הבא פועל ללא שום שגיאות ב-JavaScript:

class Test {
    constructor() {
        Object.defineProperty(this, "hello", {
            get() {return "world!"}
        })
    }
}

let greet = new Test()
console.log(greet.hello)

אבל TypeScript זורק את השגיאה הזו:

Property 'hello' does not exist on type 'Test'.

עדכון

זה דומה למה שאני צריך והייתי מנסה. קישור למגרש משחקים. אני רוצה בדיקת זמן קומפילציה עבור תכונות כי הן עשויות להשתנות בעתיד.

class ColorPalette {
    #colors = [
        ["foregroundColor", "#cccccc"], 
        ["backgroundColor", "#333333"], 
        ["borderColor", "#aaaaaa"]
    ]; 

    constructor() {
        this.#colors.forEach((e, i) => {
            Object.defineProperty(this, this.#colors[i][0], {
                enumerable: true,
                get() { return this.#colors[i][1]; },
                set(hex: string) { 
                    if (/^#[0-9a-f]{6}$/i.test(hex)) { 
                        this.#colors[i][1] = hex;
                    }
                }
            })
        });
    }

    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}
let redPalette = new ColorPalette();
// redPalette.foregroundColor = "#ff0000";  <----- error: "Property 'foregroundColor' does not exist on type 'ColorPalette'" 
console.log(redPalette.toString());

תשובה #1

יש שני דברים שמפריעים לקוד שלך לעבוד כפי שהוא.

הראשון הוא שאתה לא יכול להכריז על שדות מחלקה בצורה משתמעת בתוך גוף המתודה constructor בקוד TypeScript. אם אתה רוצה שלמחלקה יהיה מאפיין, תצטרך להכריז על מאפיין זה במפורש מחוץ ל- constructor:

class ColorPalette {
    foregroundColor: string; // <-- must be here
    backgroundColor: string; // <-- must be here
    borderColor: string; // <-- must be here
// ...

יש הצעה שנדחתה ב-microsoft/TypeScript#766 שמבקשת הצהרות כאלה בתוך הקונסטרקטור, והצעה פתוחה אך לא פעילה ב-microsoft/TypeScript#12613 שמבקשת את אותו הדבר, אך לעת עתה, זה לא חלק מהשפה.

הבעיה השנייה היא שבתוך קוד TypeScript, קריאה ל-Object.defineProperty() בתוך מחלקת בנאי אינה משכנעת את המהדר שהמאפיין הנדון הוקצה בוודאות, ולכן כאשר אתה משתמש באופציית המהדר --strict, תצטרך משהו כמו הצהרת הקצאה ודאית כדי להשתיק את האזהרות:

class ColorPalette {
    foregroundColor!: string; // declared and asserted
    backgroundColor!: string; // ditto
    borderColor!: string; // ditto
// ...

ישנה הצעה ב-microsoft/TypeScript#42919 שהקומפיילר יזהה את Object.defineProperty() כהשמת ערכים ל-properties, אבל נכון לעכשיו, זה גם לא חלק מהשפה.

אם אתה מוכן לכתוב את השם והסוג של כל נכס פעמיים, אז תוכל לגרום לקוד שלך לעבוד כפי שהיית מתכוון במקור. אם אינך מוכן, אז צריך לעשות משהו אחר.


אחת הדרכים האפשריות להתקדם היא ליצור פונקציית יצרן מחלקה שמייצרת בוני מחלקה מתוך קלט מסוים. אתה מכניס לפונקציה כמה מתארי תכונות (או יותר נכון, פונקציות שמחזירות את המתארים הללו), ומפה יוצא בונה מחלקה שמגדיר את התכונות האלה. זה יכול להיראות כך:

function ClassFromDescriptorFactories<T extends object>(descriptors: ThisType<T> &
    { [K in keyof T]: (this: T) => TypedPropertyDescriptor<T[K]> }): new () => T {
    return class {
        constructor() {
            let k: keyof T;
            for (k in descriptors) {
                Object.defineProperty(this, k, (descriptors as any)[k].call(this))
            }
        }
    } as any;
}

חתימת הקריאה משמעה: עבור כל T גנרי מסוג אובייקט, ניתן להעביר אובייקט descriptors שמפתחותיו מקבוצת T, והמאפיינים שלו הם פונקציות ללא ארגומנטים שמייצרות תיאורי מאפיין עבור כל מאפיין מתוך T; ומה שיוצא מפונקציית המפעל יש לו חתימת בנייה שמייצרת מופעים מסוג T.

ה-ThisType<T> אינו הכרחי, אך עוזר בהסקת סוג כאשר אתם מגדירים תיאורים במונחים של תכונות אחרות של המחלקה. מידע נוסף למטה:

המימוש עובר על כל המפתחות של descriptors כאשר הקונסטרוקטור נקרא, ובכל מפתח כזה k, הוא מגדיר תכונה על this עם המפתח k, והמציין של התכונה שמתקבל כאשר קוראים ל-descriptors[k].

שימו לב כי הקומפיילר אינו יכול לאמת שהמימוש תואם את חתימת הקריאה, מאותה סיבה שהוא אינו יכול לאמת את הדוגמה המקורית שלכם; לא הכרזנו על התכונות ו-Object.defineProperty() לא נראה שהוא מאתחל אותן. לכן ה-class המוחזר צוין כ-as any. הדבר מדכא כל אזהרה לגבי מימוש המחלקה, ולכן עלינו להיות זהירים שהמימוש וחתימת הקריאה תואמים.

אבל בכל מקרה, ברגע שיהיה לנו ClassFromDescriptorFactories(), נוכל להשתמש בו מספר פעמים.


לדוגמה של פלטת הצבעים שלך, אתה יכול ליצור פונקציה כללית colorDescriptor() שלוקחת מחרוזת קלט initValue, ושיוצרת פונקציה ללא פרמטרים שמחזירה תיאור של נכס עם האימות שתרצה:

function colorDescriptor(initValue: string) {
    return () => {
        let value = initValue;
        return {
            enumerable: true,
            get() { return value },
            set(hex: string) {
                if (/^#[0-9a-f]{6}$/i.test(hex)) {
                    value = hex;
                }
            }
        }
    }
}

המשתנה value משמש לאחסון הערך המחרוזתי בפועל של הצבע. כל המטרה של ההפנייה () => {...} היא שכל מופע של המחלקה הסופית יהיה לו את המשתנה האישי שלו value; אחרת, היית מסתיים בכך ש-value יהיה תכונה סטטית של המחלקה, וזה לא מה שאתה רוצה.

ועכשיו אנחנו יכולים להשתמש בזה עם ClassFromDescriptorFactories() כדי להגדיר את ColorPalette:

class ColorPalette extends ClassFromDescriptorFactories({
    foregroundColor: colorDescriptor("#cccccc"),
    backgroundColor: colorDescriptor("#333333"),
    borderColor: colorDescriptor("#aaaaaa"),
}) {
    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}

זה מקומפל ללא שגיאה, והקומפיילר מזהה מופעים של ColorPalette כבעלי תכונות בעלות ערך string במפתחות foregroundColor, backgroundColor, ו-borderColor, ובזמן הריצה לתכונות אלו יש את האימות הנכון:

let redPalette = new ColorPalette();

redPalette.foregroundColor = "#ff0000";
console.log(redPalette.toString()); // "#FF0000, #333333, #AAAAAA" 

redPalette.backgroundColor = "oopsie";
console.log(redPalette.backgroundColor) // still #333333

ורק כדי לוודא שלכל מופע יש את המאפיינים שלו, בואו ניצור מופע חדש:

let bluePalette = new ColorPalette();
bluePalette.foregroundColor = "#0000ff";
console.log(redPalette.foregroundColor) // #ff0000
console.log(bluePalette.foregroundColor) // #0000ff

כן, bluePalette ו-redPalette לא חולקים את מאפיין foregroundColor המשותף. ניראה טוב!


שימו לב שהקוד ThisType<T> שימושי במצבים כאלה, כאשר אנחנו מוסיפים מתאר חדש שמתייחס לתכונות אחרות של המחלקה:

class ColorPalette extends ClassFromDescriptorFactories({
    foregroundColor: colorDescriptor("#cccccc"),
    backgroundColor: colorDescriptor("#333333"),
    borderColor: colorDescriptor("#aaaaaa"),
    foregroundColorNumber() {
        const that = this;
        const descriptor = {
            get() {
                return Number.parseInt(that.foregroundColor.slice(1), 16);
            }
        }
        return descriptor;
    }
}) {
    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}

כאן הקומפיילר מבין כי foregroundColorNumber() מגדיר תכונה מסוג number על T, וכי this בתוך האפליקציה מתייחס ל-T, ולכן קריאות כמו הבאות פועלות ללא שגיאה:

console.log("0x" + redPalette.foregroundColorNumber.toString(16)) // 0xff0000
console.log(redPalette.foregroundColor = "#000000")
console.log("0x" + redPalette.foregroundColorNumber.toString(16)) // 0x0

אם אתה מסיר את ThisType<T>, תראה שחלק מהשגיאות מופיעות.

קישור למזחלת לקוד