首页 > 解决方案 > 如何正确键入将多个对象合并在一起的动态递归函数?

问题描述

我正在尝试键入以下函数,该函数按预期工作,但键入不正确。我希望它在我键入可用值时自动为我建议可用值。

我对 Typescript 比较陌生,但我无法弄清楚。这是我所拥有的:

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;
    };

游乐场链接

标签: javascripttypescript

解决方案


在我的回答中,我将专注于您的直接问题,而不是试图传授不同的方法或过多地改变您正在做的事情。这将需要一些解释。

类型推断

首先,最好记住 TypeScript 对所有事物都有一个类型。如果没有给出类型,则推断它。这意味着,在你这样做之后const result = {};,它有一个类型的空对象。当您返回它时,result无论您在此之前对对象做了什么,都将属于该类型。

其次,如果你"strict": true在你的tsconfig.json这是最有用的)中使用,在你以任何方式进行类型转换之前,你将不能被允许引用返回的对象中的任何内容。这又是因为类型是{}. 这不仅仅是 IDE 中的代码建议!

推断的类型

你的mergeEndpoints函数的类型实际上(endpoints: TMergeEndpoints) => {}就是{}类型,它甚至不是object

注释函数或方法的返回类型通常是一个好主意,除非它们是单行的:通过通知您实际返回的类型与声明不匹配来节省修改和重构代码的时间。

另请注意,endpointsAendpointsB以及这些函数返回的对象都是不同的不相关类型。无论您选择如何合并它们,如果不以任何方式声明它,您都无法获得具体类型。

类型铸造

最后,当你对操作符使用类型转换as时,你会失去类型系统的帮助。您基本上是在说:“不要检查任何东西,我知道它是哪种类型”。这违背了拥有健全类型系统的全部意义,当然,这就是以更少的时间和精力编写正确、安全、易于理解和维护的代码。

TypeScript 就是为了让事情变得明显和明确。允许它完成它的工作是你的责任。as忽略推断类型,使用and之类的东西破坏类型系统逻辑!,不使用严格检查,不使用 linter——所有这些都可能导致“JavaScript 具有特殊语法”,而没有任何好处。

修复

考虑到上述情况,让我们对您的代码进行一些更改。请记住,我故意不试图改变你正在做的事情的要点。

首先,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)都可以分配给 type 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(你好 JavaScript)。

    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;
            }
        }
    };

其次,当您在三元 if 中使用时,您使用的是值转换。obj[key]类型转换是 JavaScript 最可怕的事情之一,最好使用 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 的做事方式,同时不会与最初的实现有太多不同。


推荐阅读