typescript 具有通用模式支持的转换函数的正确类型

xzlaal3s  于 2023-04-22  发布在  TypeScript
关注(0)|答案(1)|浏览(118)

首先,场景:我是一个需要从一些REST API中检索数据的客户端,但是它们返回的json遵循了一个非常规的命名法(字段名称只有一个字符),我希望自动将响应Map到一个更显式的接口。
我提出了一个基于convert函数的解决方案,该函数接受我用作目标模式的mapping对象的原始响应。
这个解决方案甚至可以工作,但是我在尝试以通用(和正确)的方式设置类型时遇到了麻烦。目前我只是放了很多any,但我想了解函数和Map对象是什么类型。
(操场在此)
理想情况下,我希望将convert定义为泛型函数<T,U>,将obj定义为T,返回值为U,但我不知道如何正确地键入mapping,其类型依赖于TU

interface AB {
    a: string;
    b: C;
}

interface C {
    c: number;
}

interface XY {
    x: string;
    y: Z;
}
interface Z {
    z: number;
}

const abc: AB = {
    a: '...',
    b: {
        c: 123,
    },
};

const ab_xy = {
    a: 'x',
    b: ['y', {
        c: 'z'
    }]
};

const convert = (obj: any, mapping: {}): any => {
    const ret: any = {};

    for (const [key_orig, value] of Object.entries(mapping)) {
        const value_orig = obj[key_orig];

        if (Array.isArray(value)) {// nested
            const [key_new, submapping] = value;
            ret[key_new] = convert(value_orig, submapping);
        }
        else {
            const key_new = value as any;
            ret[key_new] = value_orig;
        }

    }

    return ret;
};

const expected_xyz: XY = {
    x: '...',
    y: {
        z: 123,
    }
};

const actual_xyz = convert(abc, ab_xy);

console.log('         abc: %o', abc);
console.log('expected_xyz: %o', expected_xyz);
console.log('  actual_xyz: %o', actual_xyz);
qyswt5oh

qyswt5oh1#

Playground:https://tsplay.dev/wOlQdN

// key mapping type: a recursive type
//    which maps 'key' to 'newKey' for renaming
//    or to ['newKey', Mapping] for deeper renaming
type Mapping<T> = {
    [K in keyof T]?: string | (
        T[K] extends Record<any, any> ? readonly [string, Mapping<T[K]>]
        : never
    )
}

// result of mapping
type Mapped<T, M extends Mapping<T>> = {
    [K in keyof M & keyof T // take keys
    as ( // and rename them
        | M[K] extends string ? M[K] // if mapping is string, to that string
        : M[K] extends readonly [infer P extends string, any] ? P // if mapping is tuple, to its first element
        : never
    )]: (
        | M[K] extends string ? T[K] // if mapping is string, keep original value
        : M[K] extends readonly [any, infer P extends Mapping<T[K]>] ? Mapped<T[K], P> // otherwise map it using 2nd tuple item
        : never
    )
}

// unwraps inferred types display to avoid intellisence saying `Mapped<T, M>` and to say `{ b: 1 }`
export type Debug<T> = T extends Record<any, any> ? { [K in keyof T]: Debug<T[K]> } : T

// advanced typings to reduce amount of `any` and `as any`. Feel free to use just `Object.fn` instead.
const recordEntries = Object.entries as <T>(o: T) => { [K in keyof T]: [K, T[K]] }[keyof T][]
const fromEntries = Object.fromEntries as <T extends readonly (readonly [any, any])[]>(a: T) => { [K in T[number][0]]: Extract<T[number], [K, any]>[1] }

function convert<T extends Record<string, any>, M extends Mapping<T>>(obj: T, mapping: M): Debug<Mapped<T, M>> {
    let entries = recordEntries(obj) // take [key, value] pairs
    let mappedEntries =
        entries.map(([key, value]) => { // map them
            let mapped = mapping[key]!
            if (typeof mapped === 'string') return [mapped, value] as const // changing key to mapping if it's a string
            else return [mapped[0], convert(value, mapped[1])] as const // changing key to mapping[0] and value to converted if it's a tuple
        })
    return fromEntries(mappedEntries) as any // the type for generic case can't be inferred, thus `as any` 
};

let test1 = convert(
    // ^?
    // let test1: { b: 1; }
    { a: 1 } as const,
    { a: 'b' } as const
)
console.log(test1)
let test2 = convert(
    // ^?
    // let test2: { aaa: { bbb: { ccc: { readonly d: 123; }; }; }; }
    { a: { b: { c: { d: 123 } } } } as const,
    { a: ['aaa', { b: ['bbb', { c: 'ccc' }] }] } as const
)

console.log(test2)

相关问题