Search code examples
typescript

TypeScript passing key to new object error


I have this typescript code. Essentially I want to pass in a keys name (K, K1) and transform it into a new object that retains the key and key value and adds an extra object. Here is my code so far

type KOutput<T, K extends keyof T> = {
    [P in K]: T[P];
} & {
    assignment: string[];
};

const transform =
    <Obj, K extends keyof Obj, K1 extends keyof Obj>(key1: K, key2: K1, obj: Obj): KOutput<Obj, K | K1> => {
        const output: KOutput<Obj, K | K1> = {
            [key1]: obj[key1],
            [key2]: obj[key2],
            assignment: ['test'],
        }

        return output;
    };

transform('key1', 'key2', { key1: 'value1', key2: 'value2' });

I'm still getting this error though for output

Type '{ [x: string]: Obj[K] | Obj[K1] | string[]; assignment: string[]; }' is not assignable to type 'KOutput<Obj, K | K1>'.
  Type '{ [x: string]: Obj[K] | Obj[K1] | string[]; assignment: string[]; }' is not assignable to type '{ [P in K | K1]: Obj[P]; }'.

How do I make typescript work?


Solution

  • TypeScript doesn't precisely represent the type of an object with a computed property if the computed key isn't of a single literal type. If the key is of a generic type like K or K1, the key is widened to string and produces a string index signature. This type isn't wrong, but it's wider than you want. See microsoft/TypeScript#13948 for more information.

    By far the easiest approach if you know something the compiler doesn't know about a type is to use a type assertion:

    const output = {
        [key1]: obj[key1],
        [key2]: obj[key2],
        assignment: ['test'],
    } as KOutput<Obj, K | K1>
    

    Assertions are easy, but you're now in charge of getting the type right. If your assertion turns out to be incorrect then you might be unhappy at runtime.


    If you want something better, than you'll need to start taking on the responsibility for telling the compiler what it can expect a computed property to look like. This turns out to be somewhat difficult. The type of {[k]: v} is not just Record<typeof k, typeof v> in general. After all, if k is of type "a" | "b" then the output will be something like {a: any} | {b: any} and not {a: any; b: any}.

    Here's a function I sometimes use to get a more precise type, at the expense of more complexity (and still you have to use a type assertion in the implementation):

    function kv<K extends PropertyKey, V>(
      k: K, v: V
    ): { [P in K]: { [Q in P]: V } }[K] {
      return { [k]: v } as any
    }
    

    Then you can refactor transform() to use it:

    const transform =
      <Obj, K extends keyof Obj, K1 extends keyof Obj>(
        key1: K, key2: K1, obj: Obj) => {
        const output = {
          ...kv(key1, obj[key1]),
          ...kv(key2, obj[key2]),
          assignment: ['test'],
        };    
        return output 
      };
    

    If you inspect that you'll see the output type is very complicated. But when you call it with reasonable inputs, you'll get the output type you expect:

    const ret = transform('key1', 'key2', { key1: 'value1', key2: 'value2' });
    // const ret: { key1: string; } & { key2: string; } & { assignment: string[]; } 
    console.log(ret.assignment.map(x => x.toUpperCase())) // ["TEST"]
    console.log(ret.key1.toUpperCase()) // "VALUE1"
    console.log(ret.key2.toUpperCase()) // "VALUE2"
    

    That type { key1: string; } & { key2: string; } & { assignment: string[]; } is equivalent to KOutput<{key1: string, key2: string}, "key1" | "key2">. But if you try to actually annotate the return type with your KOutput type you'll get an error:

    /* Type '{ [P in K]: { [Q in P]: Obj[K]; }; }[K] & 
             { [P in K1]: { [Q in P]: Obj[K1]; }; }[K1] &
             { assignment: string[]; }' is not 
      assignable to type 'KOutput<Obj, K | K1>' */
    

    And that's because it's not that type. They're only equivalent when K and K1 are single string literal types. If you call this:

    const x = transform(Math.random() < 0.5 ? "a" : "b", "c", { a: 0, b: 0, c: 0 });
    

    The version with KOutput will claim that x has a, b, and c as keys, whereas in reality it has a-or-b and c, which is what the above crazy thing gives you:

    /* const x: ({ a: number; } | { b: number; }) & { c: number; } & { assignment: string[]; } */
    

    Personally I don't know that it's worth worrying about such edge cases. But they exist, and TypeScript doesn't try to worry at all about them, instead opting for an index signature. If you want the easy road, use a type assertion. If you want the hard road, start going through the tortured logic of determining what a computed property actually implies for a type.

    Playground link to code