Search code examples
typescript

Rename the properties of an object using a conversion map in TypeScript?


I'm trying to achieve something like this:

const input = {a: 1, b: "hello"}; 
const result = renameProperties(input,{a: "alpha", b: "beta"}); 
result.alpha // number
result.beta // string
result.foo //Property 'foo' does not exist on type... 

So my conversion function is looking like this:


export function renameProperties<
  TInput extends Record<string, unknown>,
  TConversionMap extends Record<keyof TInput, string>,
  TOutput extends // heres where I get stuck 
>(input: TInput, conversionMap: TConversionMap): TOutput {

}

As a first step I've got:

TOutput extends {[K in TConversionMap[keyof TInput]] : string}

This doesn't quite behave how I want - I was hoping that the result would only contain converted keys.

result.alpha // string
result.beta // string

result.foo //string  - but really was hoping to see an error here 

The second part would involve retaining the the value from the input, I was thinking something like:

TOutput extends {[K in TConversionMap[keyof TInput]] : TInput[The Input key, if we can retain a reference to it]}

How would I do this? Is this possible?


Solution

  • You mostly want to use key remapping in mapped types. Your renameProperties() function should be generic in T, the type of the input, and M, the type of the key mapping. Then for each key K in keyof T, you make a property with key M[K]:

    declare function renameProperties<
      T extends object,
      const M extends Record<keyof T, PropertyKey>
    >(input: T, mapping: M):
      { [K in keyof T as M[K]]: T[K] }
    

    Here you can see that M has been constrained to something with the same keys as T and whose properties are all keylike themselves (PropertyKey).

    That gives you everything you technically need for this to work.


    The only wrinkle is that if you write the mapping object as an object literal for the mapping, TypeScript will not by default keep track of the literal types of the property values. If you write {a: "x", b: "y"}, TypeScript will infer that as {a: string, b: string}, which doesn't help you. You'd want a const assertion as in {a: "x", b: "y"} as const instead.

    One improvement is to make M a const type parameter, so that if the mapping argument is an object literal, TypeScript will treat it as if the const assertion were there, so the caller can leave it out:

    const input = { a: 1, b: "hello" };
    const result = renameProperties(input, { a: "alpha", b: "beta" });
    //    ^? const result: { alpha: number; beta: string; }
    

    So that works as desired. Note that if the mapping is not passed inline, then you need that const assertion. Nothing can magically make this work for you:

    const mapping = { a: "x", b: "y" };
    //    ^? const mapping: {a: string, b: string}
    const r2 = renameProperties(input, mapping);
    //    ^? const r2: { [k: string]: string | number } 
    

    So you'd want

    const mapping = { a: "x", b: "y" } as const;
    const r2 = renameProperties(input, mapping);
    //    ^? const r2: { x: number; y: string }
    

    Playground link to code