如何正确键入映射对象值的函数?

How to correctly type a function that maps object values?

我有一个给定对象和查找映射或映射函数的函数,它 return 是另一个对象,其值映射如下:

function mapValues(object, mapping) {
    const result = {}

    for (const [key, value] of Object.entries(object)) {
        if (typeof mapping === 'function') {
            result[key] = mapping(value);

            continue;
        }

        result[key] = typeof value === 'string' || typeof value == 'number' || typeof value == 'symbol'
            ? mapping[value]
            : value;
    }

    return result;
}


// Return type: {foo: 1, bar: null, baz: undefined}
console.log(mapValues({foo: 'a', bar: 'b', baz: undefined}, {a: 1, b: null}));

// Return type: {foo: number, bar: number, baz: number}
console.log(mapValues({foo: 1, bar: 2, baz: 3}, value => value + 1));

如何键入此函数签名和重载,以便 return 类型与前面的场景匹配?

我想我可能会给 mapValues() 一个 overloaded 函数类型,因为调用它的两种方式是不同的。请记住,这些是 近似值 类型,可能存在边缘情况。

--

首先,最简单的:第二个参数是函数时的调用签名:

function mapValues<T extends object, U>(
    object: T,
    mappingFn: (x: T[keyof T]) => U
): { [K in keyof T]: U };

这里我们只是说 mapValues() 将采用 generic 类型 TobjectmappingFn 本身就是一个函数T[keyof T] 类型的参数(所有 已知 属性 值类型 T)和 return 泛型类型的值 U.整个函数的 return 类型是一个对象类型,其键与 T 相同,但其值是 U.

类型

现在是难题:第二个参数是对象时的调用签名:

function mapValues<
    T extends Record<keyof T, N>,
    M extends object,
    N extends PropertyKey | null | undefined | {}
>(
    object: T, mappingObj: M
): { [K in keyof T]:
        T[K] extends PropertyKey ? T[K] extends keyof M ? M[T[K]] : unknown : T[K]
    };

让我们假设 N 暂时不存在。此函数接受某个对象类型 Tobject 和某个对象类型 MmappingObj,以及一个新对象 return。对于T中的每个键K,return值也有一个键K,其属性类型是根据属性 of T at key K (T[K]) 本身就是一个keylike thing (string or number or symbol, 也被称为PropertyKey):

  • 如果T[K]键,我们将尝试在M中查找该键。如果我们找到它,那么我们将 return 相同的 属性 获取那个键,M[T[K]]。如果我们 没有 找到它,那么坦率地说,我们不知道输出 属性 会是什么。声称它应该是 undefined 会很好,但这可能发生,因为我们不知道 T[K]literal type,或者因为 object 恰好有一个属性 在键 T[K]T 没有(TypeScript 中的对象类型是 open,而不是 closed ,因此对象可能具有比编译器知道的更多的属性)。所以这里最安全的return是unknown.

  • 如果T[K] 不是 keylike,那么我们就return T[K].

这一切都说得通。 N 的问题在于,如果我省略 N 并只写 T extends object,编译器将不会意识到它必须注意 文字 object 的属性值。它自然会推断 {foo: "a", bar: "b"}{foo: string, bar: string} 类型。如果你想让它跟踪 "a""b",你需要给它一个提示。通过使用 T extends Record<keyof T, N>,其中 N 是一个非常广泛的类型,包括 PropertyKey,编译器将其视为我们关心 T 的每个 属性 的文字类型的提示。它是 ugly/crazy。有关功能请求,请参阅 microsoft/TypeScript#30680将来减少 crazy/ugly。

哇哦!


对于实现,您可以尝试严格一些,但我只是将事物注释为 any 并假设提供类型安全的负担在实现者而不是编译器上,编译器可能可以无论如何都要完成如此复杂的条件泛型类型签名的任务:

function mapValues(object: any, mapping: any) {
    const result: any = {}
    for (const [key, value] of Object.entries(object)) {
        if (typeof mapping === 'function') {
            result[key] = mapping(value);
            continue;
        }
        console.log(key, value)
        result[key] = typeof value === 'string' || typeof value == 'number' || typeof value == 'symbol'
            ? mapping[value]
            : value;
    }

    return result;
}

让我们用你的例子来测试它:

const mappedFromFn = mapValues({ foo: 1, bar: 2, baz: 3 }, value => value + 1);
/* const mappedFromFn: {
    foo: number;
    bar: number;
    baz: number;
} */
console.log(mappedFromFn); // {foo: 2, bar: 3, baz: 4 }

看起来不错。编译器推断回调函数中未注释的 value 参数将是 number 类型,并且 mappedFromFn 将在 foo 处具有 number 属性,barbaz 键。这在运行时也是正确的。

还有这个:

const mappedFromObj = mapValues({ foo: 'a', bar: 'b', baz: undefined }, { a: 1, b: null });
/* const mappedFromObj: {
    foo: number;
    bar: null;
    baz: undefined;
} */
console.log(mappedFromObj); // {foo: 1, bar: null, baz: undefined }

看起来也不错。编译器跟踪 object 中的 "a""b",并将 foo 属性 映射到 numberbar 属性 到 null,同时单独保留 baz 属性。结果在运行时也是如此。


正如我所说,这很可能存在边缘情况,因此您必须进行测试以了解它是否适​​用于您的用例,如果不适用,您可能需要进行一些调整才能按照您的想法进行操作合理的。但这是我会遵循的一般方法。

Playground link to code

如果有人正在寻找准备就绪的实施,我最终得到了以下实施:

export function mapValues<T extends object, V>(object: T, mapper: (value: T[keyof T]) => V): {[K in keyof T]: V};

export function mapValues<
    T extends {[key in keyof T]?: N},
    M extends object,
    N extends PropertyKey | undefined | null | {}
>(object: T, mapping: M): {
    [K in keyof T]: T[K] extends keyof M ? M[T[K]] : T[K]
};

export function mapValues<I extends Record<any, any>, R extends Record<keyof I, any>>(
    object: I,
    mapping: ((value: I[keyof I]) => R[keyof R]) | Partial<Record<I[keyof I], R[keyof R]>>
): R {
    const result: Partial<R> = {};

    for (const [key, value] of Object.entries(object) as [keyof I, I[keyof I]][]) {
        if (typeof mapping === 'function') {
            result[key] = (mapping as (value: I[keyof I]) => R[keyof R])(value);

            continue;
        }

        result[key] = value in mapping ? mapping[value] : value;
    }

    return result as R;
}

谢谢@jcalz!