typescripttypescript-genericstypescript-typestypescript-conditional-types

How to write a TypeScript function that maps (translates) an object into another object and infers the return type?


I'm trying to write a TypeScript function that takes an input object and returns a result object with properties derived from the input.

This is what I have so far: (this function should just create a new object with renamed keys)

type t1 = {
  a: number,
  b: number,
  c: number
}

type t2 = {
  i: number,
  j: number,
  k: number
}

function translate(input: Partial<t1>): Partial<t2> {
  const result: Partial<t2> = {};
  if("a" in input) result["i"] = input["a"];
  if("b" in input) result["j"] = input["b"];
  if("c" in input) result["k"] = input["c"];
  return result;
}
const result = translate({
  a: 1, b: 2
});
console.log(result); // logs { i: 1, j: 2 }

this works, but I want my result to be strongly typed based on the input object. For example, if the input object is { a: 1 }, the function should return an object of type { i: number }. If the input object is { a: 1, b: 2 }, the function should return an object of type { i: number, j: number } etc.

Update:

This is what I have now based on @user137794 's answer:

type t1 = {
  a: number,
  b: number,
  c: number
}

type t2 = {
  i: number,
  j: number,
  k: number
}

/** this defines how field names are translated */
const renameMap = { a: 'i', b: 'j', c: 'k' } as const;

type RenameKeys<T, KeyMap extends Record<string, string>> = {
  [K in keyof T as K extends keyof KeyMap ? KeyMap[K] : never ]: T[K]
}

function translate<T extends Partial<t1>> (input: T): RenameKeys<T, typeof renameMap> {
  const result: Partial<t2> = {};
  for(const key of Object.keys(renameMap) as (keyof typeof renameMap)[]) 
    if (key in input) result[renameMap[key]] = input[key];
  return result as RenameKeys<T, typeof renameMap>;
}
const result = translate({
  a: 1, b: 2
});
console.log(result); // logs { i: 1, j: 2 }

This function returns the a strongly typed object based on the input. It only works for simple 1:1 key renaming however. What should I do for more complex functions, where multiple fields from input object are mapped to a single output field?

It would be ideal if I could define the maping logic something like this:

const translationMap = { 
  i: (sourceObject: t1) => sourceObject.a, 
  j: (sourceObject: t1) => sourceObject.a + sourceObject.b,
  k: (sourceObject: t1) => sourceObject.b || sourceObject.c,
}

and have TypeScript infer the type of each property in the result automatically. If that's not possible, any other workarounds are welcome


Solution

  • To answer my own question:

    This function translates an object into another object and returns a strongly typed result. It works well with complex translation logic

    type ComputedMap<SRC> = {
      [ATTR: string]: (obj: SRC) => unknown;
    };
    type MappedReturnType<SRC, CM extends ComputedMap<SRC>> = { [ATTR in keyof CM]: ReturnType<CM[ATTR]> };
    
    /**
     *
     * @param src source object
     * @param computedMap key-function map describing how properties should translate. Functions Take the source object as an argument
     * @returns
     */
    function transformObject<SRC extends object, CM extends ComputedMap<SRC>>(
      src: SRC,
      computedMap: CM
    ): MappedReturnType<SRC, CM> {
      const result: Partial<Record<string, unknown>> = {};
      for (const key in computedMap) {
        const transformer = <(obj: SRC) => unknown>computedMap[<string>key];
        result[key] = transformer(src);
      }
      return result as MappedReturnType<SRC, CM>;
    }
    
    type t1 = {
      a?: number,
      b?: number,
      c?: number,
    }
    const src: t1 = {
      a: 1,
      b: 2,
      c: 3,
    }
    
    const r = transformObject(src, {
      i: (o) => o.a,
      j: (o) => o.a && o.b && (o.a + o.b),
      k: (o) => !(o.b || o.c),
      a: (o) => o.c,
    });
    
    console.log(r); // logs { "i": 1, "j": 3, "k": false, "a": 3 }
    

    (based on this answer by @Matt McCutchen)