Infer types from key value pairs, where the values

2020-07-24 05:06发布

问题:

I'm creating a mapping like function that is going to turn an object like this:

const configObject: ConfigObject = {
    a: {
        oneWay: (value: string) => 99,
        otherWay: (value: number) => "99"
    },


    b: {
        oneWay: (value: number) => undefined,
        otherWay: () => 99
    }
}

into:

{
    foos: {
        a: {
            convert: (value: string) => 99,
        },
        b: {
            convert: (value: number) => undefined
        }
    },


    bars: {
        a: {
            deconvert: (value: number) => "99",
        },
        b: {
            deconvert: () => 99;
        }
    }
}

The issue I'm having is around enforcing the function parameter and return types, based on the ConfigItem's signatures.

The way I'm doing it looks like this:

interface ConfigItem<P, Q> {
    oneWay: (value: P) => Q;
    otherWay: (value: Q) => P;
}

type ConfigObject = Record<string, ConfigItem<any, any>>; //This is right, I believe. 
// any is explicitly an OK type for the ConfigItems to have. 

interface Foo<A, B> {
    convert: (a: A) => B;
}

interface Bar<A, B> {
    deconvert: (b: B) => A;
}

interface MyThing<T extends ConfigObject> {
    foos: Record<keyof T, Foo<any, any>> //These are wrong - they should use the types as defined by the config object
    bars: Record<keyof T, Bar<any, any>>
}

I later implement a function to create a MyThing like:

function createMyThing<T extends ConfigObject>(configObject: T): MyThing<T> {
    //I would use Object.entries, but TS Playground doesn't like it. 
    const keys = Object.keys(configObject);
    return {
        foos: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    convert: configObject[key].oneWay
                }
            }
        }, {} as Record<keyof T, Foo<any, any>>), //Again problematic 'any' types. 

        bars: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    deconvert: configObject[key].otherWay
                }
            };

        }, {}) as Record<keyof T, Bar<any, any>>

    };
}

Now this code works:



const configObject: ConfigObject = {
    a: {
        oneWay: (value: string) => 99,
        otherWay: (value: number) => "99"
    },


    b: {
        oneWay: (value: number) => undefined,
        otherWay: () => 99
    }
}
const myThing = createMyThing(configObject); 

console.log(myThing.foos.a.convert("hello"));  
console.log(myThing.foos.b.convert("hello"));  //No type enforcement!

But we don't have any type enforcement, due to those any statements.

How would I modify my code to make this work?

Full TypeScript playground here.

Second attempt at a solution using the infer keyword

回答1:

First thing you should consider is to not set configObject type to ConfigObject, because you lose the structure of the object. Create concrete interface which extends ConfigObject instead:

interface ConcreteConfigObject extends ConfigObject{
    a: ConfigItem<string, number>;
    b: ConfigItem<number, undefined>;
}

In MyThing to get rid of anys you can extract types from configObject with combining several TS features:

  • Parameters<T> - Constructs a tuple type of the types of the parameters of a function type T
  • ReturnType<T> - Constructs a type consisting of the return type of function T
  • Index types - With index types, you can get the compiler to check code that uses dynamic property names. For example, to pick a subset of properties
  • Mapped Types - Mapped types allow you to create new types from existing ones by mapping over property types

With above we extract argument and return types from oneWay and otherWay methods to set into Foo<A, B> and Bar<A, B>:

interface MyThing<T extends ConfigObject> {
    foos: MyThingFoo<T>;
    bars: MyThingBar<T>;
}

type MyThingFoo<T extends ConfigObject> = {
    [k in keyof T]: Foo<Parameters<T[k]["oneWay"]>[0], ReturnType<T[k]["oneWay"]>>;
}

type MyThingBar<T extends ConfigObject> = {
    [k in keyof T]: Bar<ReturnType<T[k]["otherWay"]>, Parameters<T[k]["otherWay"]>[0]>;
}

TypeScript Playground

P.S. Extracting types from T looks ugly and there can be done some optimizations, I've just written it explicitly for illustration purpose.



回答2:

In Typescript it is possible to extract type signature from an existing value with typeof:

const configObject = {
    a: {
        oneWay: (value: string) => 99,
        otherWay: (value: number) => "99"
    },


    b: {
        oneWay: (value: number) => undefined,
        otherWay: () => 99
    }
};

type ConfigObject = typeof configObject

Based on ConfigObject, you can create MyThing like:

type MyThing = {
    foos: { [K in keyof ConfigObject]: { convert: ConfigObject[K]['oneWay']}}
    bars: { [K in keyof ConfigObject]: { deconvert: ConfigObject[K]['otherWay']}}
} 

For completeness createMyThing may be typed as:

function createMyThing(configObject: ConfigObject) {
    const keys = Object.keys(configObject) as (keyof ConfigObject)[];

    return {
        foos: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    convert: configObject[key].oneWay
                }
            }
        }, {}),

        bars: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    deconvert: configObject[key].otherWay
                }
            };

        }, {}),
    } as MyThing;
}

Demo



回答3:

type PropType<T, K extends keyof T> = T[K];
type FooObjType<T> = { [M in keyof T]: Foo<T[M] extends ConfigItem<any, any> ? PropType<T[M], 'oneWay'> : any> }
type BarObjType<T> = { [M in keyof T]: Bar<T[M] extends ConfigItem<any, any> ? PropType<T[M], 'otherWay'> : any> }

Note: I added a check to see if T[M] value extends ConfigItem just a precaution.

These types will definition can help. The name of the types are arbitrary.

const configObject = {
    a: {
        oneWay: (value: string) => 99,
        otherWay: (value: number) => "99"
    } as ConfigItem<string, number>,


    b: {
        oneWay: (value: number) => undefined,
        otherWay: () => 99
    } as ConfigItem<number, undefined>
}

adding an conversion of each object in the configObject.

interface MyThing<T extends ConfigObject> {
    foos: FooObjType<T>,
    bars: BarObjType<T>
}

updated the MyThing interface

function createMyThing<T extends ConfigObject>(configObject: T): MyThing<T> {


    const keys = Object.keys(configObject);
    return {

        foos: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    convert: configObject[key].oneWay
                }
            }
        }, {} as FooObjType<T>),

        bars: keys.reduce((acc, key) => {
            return {
                ...acc,
                [key]: {
                    deconvert: configObject[key].otherWay
                }
            };

        }, {} as BarObjType<T>),

    };
}

updated createMyThing function.

interface ConfigItem<P, Q> {
    oneWay: (value?: P) => Q;
    otherWay: (value?: Q) => P;
}

small modification to the function signatures in ConfigItems interface to allow empty params