Shane Callanan
Shane Callanan

Reputation: 521

How to map the structure of one generic type to another with custom transformation functions, such that it remains type-safe at compile-time?

I'd like to know if it's possible to create a function that can map one generic object to another, using custom transformations. Something like:

interface Input {
    str: string;
    num: number;
}

interface Output {
    str: number;
    num: string;
}

where...

const input: Input = {
    str: "78",
    num: 1
}

const output: Output = transform(transformations)(input); 
// where 'transformations' is a bunch of callbacks to provide custom transformation

E.g. 2 such callbacks might look like

type Transform<T, R> = (val: T) => R;
const transformString: Transform<string, number> = (val) => parseFloat(val);
const transformNumber: Transform<number, string> = (val) => `${val}`;

Here is a playground link of what I currently have: https://tsplay.dev/wEVQ3N

Upvotes: 0

Views: 234

Answers (2)

jcalz
jcalz

Reputation: 328302

While TypeScript doesn't directly support higher kinded types of the sort needed to express completely arbitrary type transformations (as requested in microsoft/TypeScript#1213), you might be able to compose a few basic transformations that meet your needs.

Let's start with your Transform<I, O> function type that takes an input of type I and produces an output of type O:

type Transform<I, O> = (val: I) => O;

If you have an object type T whose properties are each Transformations, you can use that to create a Transform<I, O> where I is an object type with the same keys as T and whose property values come from all the transformation input types, and where O is an object type with the same keys as T and whose property values come from all the transformation output types. Let's call this MapObject<T>:

type MapObject<T extends Record<keyof T, Transform<any, any>>> = Transform<
    { [K in keyof T]: T[K] extends Transform<infer I, any> ? I : never },
    { [K in keyof T]: T[K] extends Transform<any, infer O> ? O : never }
>;

For example, MapObject<{a: Transform<V, W>, b: Transform<X, Y>}> will be a Transform<{a: V, b: X}, {a: W, b: Y}. And you can make a function mapTransforms() that takes a transformMap of type T and produces an output of type MapObject<T>, by iterating through the object entries of transformMap:

function mapTransforms<T extends Record<keyof T, Transform<any, any>>>(
  transformMap: T
): MapObject<T> {
    return (x: any): any =>
        Object.fromEntries((Object.entries(x) as Array<[keyof T, any]>)
          .map(([k, v]) => [k, transformMap[k](v)]));
}

Here I've used any a few times to stop the compiler from complaining about the implementation of mapTransforms(). While you can express "TMapObject<T>" in the type system, it's hard/impossible to get the compiler to actually verify that a particular function implementation is of that type. Using any circumvents that... meaning I have to take care that the implementation is correct; the compiler won't be able to catch mistakes here.


To test this, let's define three functions of type Transform<string, number>, Transform<number, string>, and Transform<boolean, A<boolean>>:

const str2Num = (x: string) => +x;
const num2Str = (x: number) => "" + x;
const bool2A = (x: boolean): A<boolean> => ({ name: x });

and call mapTransforms() to turn them into a Transform<Input, Output>:

const inputToOutput: Transform<Input, Output> = mapTransforms({
    x: str2Num,
    y: num2Str,
    z: bool2A,
    nest: mapTransforms({
        a: num2Str
    })
});

That compiles with no error, so the compiler at least thinks that the output of mapTransforms() is of the desired type. And we can test it with your example at runtime:

const input: Input = {
    x: "22",
    y: 33,
    z: false,
    nest: {
        a: 66
    }
}
const output = inputToOutput(input);

console.log(JSON.stringify(output));
// {"x":22,"y":"33","z":{"name":false},"nest":{"a":"66"}}

Looks good!

Playground link to code

Upvotes: 2

Shane Callanan
Shane Callanan

Reputation: 521

Here is the approach I used to solve this, however it requires a set of defined transformations (e.g. in a Transformations object) to achieve it. This doesn't allow me to add new transformations easily, as any changes must be reflected in the Transformations object as well a couple of other types as well, and then handled specifically in createGenericObjectMapper.

I'll leave this solution here for anyone who might find it valuable.

https://tsplay.dev/Nd3nMw

Upvotes: 0

Related Questions