Search code examples
typescriptmapped-types

TypeScript - Add dynamically named property to return type


Let's say I have a function that takes an object, a key and a value, then returns a new object that extends the original object adding the key and value.

function addKeyValue(obj: Object, key: string, value: any) {
  return {
    ...obj,
    [key]: value
  }
}

QUESTION: How can I type this function so that if I call it like so:

const user = addKeyValue({ name: 'Joe' }, 'age', 30);

The compiler knows that {user} is of type { name: string, age: number }

NOTE: I know I can do this without a function, but the goal of this question is to do it with a function. (This example is based on a more complicated problem and has been simplified for brevity).

Here's what I was thinking, but it doesn't work. :(

function addKeyValue<TInput>(
    obj: TInput,
    key: string,
    value: any): TInput & { [key]: typeof value } {

    return {
        ...obj,
        [key]: value
    }
}

Solution

  • From the caller's point of view, you probably want the signature to be something like one of these:

    declare function addKeyValue<T extends {}, K extends keyof any, V>(obj: T, key: K, value: V): 
      T & Record<K, V>;
    const user = addKeyValue({ name: 'Joe' }, 'age', 30); //  { name: string; } & Record<"age", number>
    user.age // number
    user.name // string
    

    That's the pre-TypeScript 2.8 version, in which intersection is the best way to represent what you're doing. You might not be happy with the type {name: string} & Record<"age", number>} but it acts like what you want.

    You could also use conditional types:

    declare function addKeyValue2<T extends {}, K extends keyof any, V>(obj: T, key: K, value: V):
      { [P in keyof (T & Record<K, any>)]: P extends K ? V : P extends keyof T ? T[P] : never };
    const user2 = addKeyValue2({ name: 'Joe' }, 'age', 30); //  { age: number; name: string; } 
    user2.age // number
    user2.name // string
    

    This has the advantage of the return type looking like what you expect, but the disadvantage of being complex and possibly brittle (might not always behave as expected with unions, etc).

    Another possible advantage of this version is that if key overlaps the keys of obj, the property type will be overwritten:

    const other2 = addKeyValue2({ name: 'Joe', age: 30 }, 'name', false); 
    //  { name: boolean; age: number } 
    

    instead of intersected, which is probably not what you want:

    const whoops = addKeyValue({ name: 'Joe', age: 30 }, 'name', false).name; // never
    // boolean & string ==> never
    

    None of that makes the implementation type check properly. For that you should use whatever type assertions or overloads will do the job:

      return {
        ...(obj as any), // simplest way to silence the compiler
        [key]: value
      }
    

    Hope that helps. Good luck!