جاوااسکریپت - چگونه یک تابع بازگشتی داینامیک که چندین شیء را با هم ادغام میکند به طور صحیح تایپ کنیم؟


من دارم سعی میکنم تابع زیر را تایپ کنم، که همانطور که انتظار میرفت کار میکند، اما بهدرستی تایپ نشده است. من میخواهم هنگام تایپ کردن، مقادیر موجود برای من بهصورت خودکار پیشنهاد شوند.

من نسبتاً تازهکار در تایپاسکریپت هستم، اما نتوانستهام آن را بفهمم. این چیزی است که دارم:

const endpointsA = (base: string) => ({
    GET: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA`,
        },
    },
});

const endpointsB = (base: string) => ({
    GET: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },
    POST: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },
});

type TMergeEndpoints = {
    [key: string]: Record<string, unknown>;
}[];
type TRecursiveMerge = {
    obj: {
        [key: string]: Record<string, unknown>;
    };
    entries: Array<[string, Record<string, unknown>]>;
};
const mergeEndpoints = (endpoints: TMergeEndpoints) => {
    const result = {};

    const recursiveMerge = ({ obj, entries }: TRecursiveMerge) => {
        for (const [key, value] of entries) {
            if (typeof value === 'object') {
                obj[key] = obj[key] ? { ...obj[key] } : {};
                recursiveMerge({
                    obj: obj[key] as TRecursiveMerge['obj'],
                    entries: Object.entries(value) as TRecursiveMerge['entries'],
                });
            } else {
                obj[key] = value;
            }
        }

        return obj;
    };

    endpoints.forEach((endpoint) => {
        recursiveMerge({ obj: result, entries: Object.entries(endpoint) });
    });

    return result;
};

const base = '/api';

const endpoints = mergeEndpoints([
    endpointsA(base),
    endpointsB(base),
]);

console.log('endpoints', endpoints);

این به درستی شیء من را ادغام میکند، با این حال هیچ پیشنهاد نوعی دریافت نمیکنم. من سعی کردهام با <T> بازی کنم و آن را اینجا و آنجا قرار دهم، اما نتیجه چندان خوب نبود. در اینجا چه کاری میتوانم انجام دهم؟

محل بازی تایپاسکریپت

ویرایش

در نهایت از پیشنهاد مایکل استفاده کردم و چیزی شبیه به این گرفتم:

/**
    Each type has a definition for each entry that it has.

    For example, 

    const a = (base: string): A => ({
        GET: {
            FEATURE_A: {
                 SOME_PATH: `${base}/some-path`
            }
        }
    });

    type A = {
        GET: {
            FEATURE_A: {
                 SOME_PATH: string
            }
        }
    }
*/
type TEndpoints = A & B & C;

const endpoints = mergeEndpoints<TEndpoints>([a(base), b(base), c(base)]);

/**
    `mergeEndpoints` stayed the same with one exception (I pass T into it during runtime)
*/

    type TMergeEndpoints = {
        [key: string]: Record<string, unknown>;
    }[];
    type TRecursiveMerge = {
        obj: {
            [key: string]: Record<string, unknown>;
        };
        entries: Array<[string, Record<string, unknown>]>;
    };
    export const mergeEndpoints = <T>(endpoints: TMergeEndpoints): T => {
        const result = {};

        const recursiveMerge = ({ obj, entries }: TRecursiveMerge) => {
            for (const [key, value] of entries) {
                if (typeof value === 'object') {
                    obj[key] = obj[key] ? { ...obj[key] } : {};
                    recursiveMerge({
                        obj: obj[key] as TRecursiveMerge['obj'],
                        entries: Object.entries(value) as TRecursiveMerge['entries'],
                    });
                } else {
                    obj[key] = value;
                }
            }

            return obj;
        };

        endpoints.forEach((endpoint) => {
            recursiveMerge({ obj: result, entries: Object.entries(endpoint) });
        });

        return result as T;
    };

لینک زمین بازی

پاسخ شماره ۱

در پاسخ خود قصد دارم بر سؤال فوری شما تمرکز کنم بدون اینکه سعی کنم رویکرد متفاوتی ارائه دهم یا کاری که انجام میدهید را خیلی تغییر دهم. این نیازمند توضیحاتی خواهد بود.

استنباط نوع

ابتدا خوب است به خاطر سپرد که TypeScript برای همه چیز یک نوع دارد؛ اگر نوعی داده نشود، استنتاج میشود. این به این معنی است که بعد از اینکه const result={}; را انجام میدهید، نوع آن یک شیء خالی خواهد بود. وقتی آن را برمیگردانید، result فارغ از اینکه قبل از آن چه کاری روی شیء انجام دادهاید، از آن نوع خواهد بود.

ثانیاً، اگر در فایل tsconfig.json خود از "strict": true استفاده کنید (که بسیار مفید است)، اجازه نخواهید داشت به هیچ چیزی در داخل شیء بازگردانده شده قبل از اینکه به هر نحوی تایپ کیست کنید، مراجعه کنید. این مجدداً به این دلیل است که نوع آن {} است. فقط پیشنهادهای کد در محیط توسعه شما نیست!

نوعهای استنباطشده

نوع تابع mergeEndpoints شما در واقع (endpoints: TMergeEndpoints) => {} است که در آن {} بهطور واقعی نوع است و حتی object نیست.

به طور کلی ایده خوبی است که نوع بازگشتی توابع یا متدها را حاشیهنویسی کنید، مگر اینکه آنها تکخطی باشند: این کار هنگام ویرایش و بازسازی کدتان زمان صرفهجویی میکند زیرا به شما اطلاع میدهد نوع واقعی بازگشتی با اعلامیه مطابقت ندارد.

همچنین توجه داشته باشید که endpointsA و endpointsB، و همچنین اشیایی که توسط این توابع بازگردانده میشوند، همگی از انواع مختلف و نامرتبط هستند. مهم نیست که چگونه تصمیم به ادغام آنها بگیرید، بدون اعلام صریح نمیتوانید یک نوع مشخص به دست آورید.

تبدیل نوع

در نهایت، زمانی که از تبدیل نوع با عملگر as استفاده میکنید، کمک سیستم نوع را از دست میدهید. شما در اصل میگویید: «هیچ چیزی را بررسی نکن، من میدانم نوع آن چیست». این کل هدف داشتن یک سیستم نوع مطمئن را نقض میکند، که البته، نوشتن کدی است که صحیح، ایمن، آسان برای درک و نگهداری با زمان و تلاش کمتر باشد.

تایپاسکریپت تماماً دربارهٔ واضح و صریح کردن موارد است. این بر عهدهٔ شماست که اجازه دهید وظایفش را انجام دهد. نادیده گرفتن نوعهای استنتاجشده، شکستن منطق سیستم نوع با استفاده از چیزهایی مانند as و !، استفاده نکردن از بررسیهای سختگیرانه، استفاده نکردن از لینتر—همهٔ اینها ممکن است فقط منجر به داشتن «جاوااسکریپت با نحو عجیب» شود، بدون بهرهمندی از مزایا.

رفعها

با توجه به موارد فوق، بیایید برخی تغییرات را در کد شما اعمال کنیم. به خاطر داشته باشید که من عمدتاً سعی ندارم جوهره کاری که انجام میدهید را تغییر دهم.

اول، Record<string, unknown> یک هر شیء است. نیازی به ایجاد یک رابط پوششدهنده دیگر نیست.

// Passing a list of whatever objects
const mergeEndpoints = (endpoints: Record<string, unknown>[]) => {

    // ... TODO

}

دوم، بیایید تصمیم بگیریم نوع ادغام شده باید چه باشد. مگر اینکه بخواهید یک رابط برای نقاط انتهایی اعلام کنید، واضحترین گزینه دوباره Record<string, unknown> است. توجه داشته باشید که نوع بازگشتی میتواند برداشت شود بدون اینکه آن را حاشیهنویسی کنید، اما داشتن حاشیهنویسی باعث میشود همه چیز واضحتر شود و از تغییر تصادفی نوع بازگشتی جلوگیری میکند: کد دیگر کامپایل نخواهد شد.

// Annotating the return type
const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};
    
    // ... TODO

    return result;
}

اکنون endpointsA(base) و endpointsB(base) هر دو به نوع Record<string, unknown> قابل انتساب هستند. این فراخوانی قابل کامپایل خواهد بود:

const endpoints = mergeEndpoints([
    endpointsA(base), // Type check passes, because Record<string, unknown> is any object
    endpointsB(base), 
]);

بیایید بدنهٔ تابع ترکیب را تغییر دهیم. Object.entries() یک آرایه از زوجها [string, unknown] برمیگرداند. میتوانیم از این نوع همانطور که هست استفاده کنیم بدون اینکه آن را در یک نوع دیگر قرار دهیم. همچنین، چه فایدهای دارد که یک نوع جدید TRecursiveMerge اعلام کنیم فقط برای اینکه بلافاصله آن را به فیلدهای جداگانه تجزیه کنیم؟ بیایید آن را حذف کنیم! (گزینهٔ دیگر میتواند حذف تجزیه باشد).

const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};

    // There is nothing wrong in just passing two arguments
    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        
        // ... TODO

    };

    endpoints.forEach((endpoint) => {
        // Passing target object and something to copy
        recursiveMerge(result, Object.entries(endpoint));
    });

    return result;
};

حالا، دو چیز باقی مانده است: اول این که typeof null برابر با object است 🤷♂️ (سلام جاوااسکریپت).

    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        for (const [key, value] of entries) {
            if (typeof value === 'object' && value != null) {
                
                // ... TODO

            } else {
                obj[key] = value;
            }
        }
    };

ثانیاً، زمانی که از obj[key] در یک شرط سهتایی استفاده میکنید، از تبدیل به مقدار صحیح استفاده میکنید. تبدیل نوع یکی از وحشتهای عجیب و غریب جاوااسکریپت است و بهتر است از صراحت TypeScript استفاده کنید. کاری که میخواهیم انجام دهیم همان کاری است که تازه انجام دادیم: بررسی کنیم که مقدار null نباشد و یک شیء باشد. برای جلوگیری از تکرار کد میتوانیم از یک محافظ نوع استفاده کنیم:

// When used in an if clause, this function lets the type system know
// the type of a given object using a custom check
const isNonNullObject = (o: unknown): o is Record<string, unknown> => {
    return typeof o === 'object' && o != null;
};

پس تابع حاصل شبیه این به نظر میرسد:

const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};

    // A simple type guard to avoid duplicating the check
    const isNonNullObject = (o: unknown): o is Record<string, unknown> => {
        return typeof o === 'object' && o != null;
    };

    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        for (const [key, value] of entries) {
            if (isNonNullObject(value)) {
                // Here the type system is aware that `value` is an object and is not null
                
                // Using the type guard again to check the existing value:
                // this makes the type system aware of the type of `existing`
                const existing = obj[key];
                const copy = isNonNullObject(existing) ? { ...existing } : {};

                recursiveMerge(copy, Object.entries(value));
                obj[key] = copy;
            } else {
                obj[key] = value;
            }
        }
    };

    endpoints.forEach((endpoint) => {
        recursiveMerge(result, Object.entries(endpoint));
    });

    return result;
};  

و در نهایت، با قرار گرفتن همه چیز در جای خود، شیء endpoints ادغام شده شما به Record<string, unknown> تبدیل میشود. این به نظر نمیرسد خیلی مفید باشد و ممکن است بخواهید ایجاد اینترفیسها/نوعها/کلاسها برای نقاط ورود را در نظر بگیرید. این چیزی است که ممکن است شبیه به آن باشد:

interface Feature {
    SOME_PATH: string;
}
interface Endpoint {
    GET: {
        FEATURE_A?: Feature;
        FEATURE_B?: Feature;
    };
    POST: {
        FEATURE_A?: Feature;
        FEATURE_B?: Feature;
    };
}

در اینجا یک زمین بازی از همه چیزهایی که در اینجا شرح داده شده است وجود دارد.

نتیجهگیری

راههای زیادی برای انجام تمام این کارها به صورت متفاوت وجود دارد. هدف این تمرین نشان دادن روش TypeScript در انجام کارها بود در حالی که خیلی از پیادهسازی اولیه منحرف نشویم.

پاسخ شماره ۲

1

بیایید مشکل شما را به چند قسمت کوچکتر تقسیم کنیم.

اول از همه، بیایید از همه نوع تبدیلها خلاص شویم، منظورم عملگر as است.

اینجا، شما از تبدیل نوع استفاده میکنید:

obj: obj[key] as TRecursiveMerge['obj'],

زیرا نوع شما بازگشتی نیست. برای بازگشتی کردن آن، باید به جای type از interface استفاده کنید.

به مثال نگاه کنید:

interface RecursiveRecord {
    [prop: string]: Record<string, RecursiveRecord>
}

type TRecursiveMerge = {
    obj: RecursiveRecord;
    entries: Array<[string, Record<string, unknown>]>;
};

اکنون، هیچ خطایی در نزدیکی obj: obj[key] وجود ندارد

2

سعی کنید به جای حلقه for .. of از reduce استفاده کنید.

تایپاسکریپت وقتی شما در حال تغییر اشیاء خود هستید، به خوبی عمل نمیکند.

مثل کاری که اینجا انجام دادی: obj[key]=

3

سعی کنید از نوعهای مقاومتر برای اشیاء خود استفاده کنید:

type Feature<Name extends string, Base extends string> = {
    [P in Name as `feature_${Name}`]: {
        SOME_PATH: `${Base}/${P}`

    }
}
type Result = Feature<'A', '/api'>

4

اگر شیء پیکربندی از قبل تعریف شده است، برای ادغام آن از بازگشت استفاده نکنید.

تابع خود را به چند تابع کوچکتر تقسیم کنید.

فرض کنید شما دو جسم زیر را دارید:

    GET: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA`,
        },
    },

و

    GET: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },

5

اینجا پیشنهاد من برای راه حل است:

    const a = {
        GET: {
            FEATURE_A: {
                SOME_PATH: '/featureA',
            },
        },
        POST: {
            FEATURE_A: {
                SOME_PATH: `${base}/featureA` as const,
            },
        },
    };

    const b = {
        GET: {
            FEATURE_B: {
                SOME_PATH: '/featureB',
            },
        },
        POST: {
            FEATURE_B: {
                SOME_PATH: `${base}/featureB` as const,
            },
        },
    };
    type A = typeof a;
    type B = typeof b;

    type CommonKeys = keyof (A | B);

    /**
     * Typesafe check if object has own property, no somewhere in the prototype chain
     */
    const hasProperty = <T, Prop extends string>(obj: T, prop: Prop): obj is T & Record<Prop, unknown> =>
        Object.prototype.hasOwnProperty.call(obj, prop)

    /**
     * Get common/sharable keys between two objects
     */
    const getCommonKeys = <Keys extends string>(a: Record<Keys, unknown>, b: Record<Keys, unknown>) =>
        // here, I'm using `filter` as a typeguard
        Object.keys(a).filter((key): key is CommonKeys => hasProperty(b, key));

    type Result = Record<CommonKeys, A[CommonKeys] & B[CommonKeys]>;

    const result = getCommonKeys(a, b).reduce((acc, elem) => ({
        ...acc,
        [elem]: {
            ...a[elem],
            ...b[elem]
        }

    }), {} as Result);

بهروزرسانی

const base = '/api' as const

const a = {
    GET: {
        FEATURE_A: {
            SOME_PATH: '/featureA',
        },
    },
    POST: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA` as const,
        },
    },
}

const b = {
    GET: {
        FEATURE_B: {
            SOME_PATH: '/featureB',
        },
    },
    POST: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB` as const,
            SOME_PATH2: `${base}/featureB_2` as const,

        },
    },
}


const merge = <T, U>(a: T, b: U) => ({ ...a, ...b })

const result = merge(a, b).POST.FEATURE_B.SOME_PATH2 // ok