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
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 any
s 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.
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
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