typescriptunion-typesobject-object-mapping

How do I eliminate union with undefined in partial index type?


I have an objectMap() function which takes an object, applies a function to each value, and returns an object with the same keys but different values. I've written the following declaration for it:

declare function objectMap<TInputObject, TOutputValue>(
        target: TInputObject,
        callback: (currentValue: TInputObject[keyof TInputObject]) => TOutputValue,
    ): {[k in keyof TInputObject]: TOutputValue};

I expect this to call the callback function with the type found in the object's values, and return an object with the same key-structure as the input object. This works perfectly with most types of object, such as simple maps and even unions of string literals:

type in1 = {[k: string]: string}

type keys = 'a' | 'b' | 'c'
type in2 = {[k in keys]: number}

but it breaks down with optional keys of string literal union types:

type keys = 'a' | 'b' | 'c'
type in = {[key in keys]?: number }

In this case, when I try to use the function:

const obj : {[key in keys]?: number} = {a: 1, b: 2}
const result = objectMap(obj, x => x+1)

The variable x gets the type number | undefined (even though all of the keys that exist have an actual number value), and result also winds up having undefineds in addition to the optional keys.

How can I change the declaration of objectMap() so that I get the expected results, where x is simply number and result is {[k in keys]?: number?


Solution

  • I suppose it depends on the implementation of objectMap(); let's assume that for each key k in Object.keys(target), you first make sure that target[k] is not undefined (which can happen if k is an optional key) before calling callback on it. If so, then you can slightly modify the signature of objectMap() so that the callback parameter does not need to worry about undefined parameters, by using the predefined conditional type Exclude<U, X>... which takes a union type U and removes any constituents assignable to X, like so:

    function objectMap<I, O>(
      target: I,
      callback: (currentValue: Exclude<I[keyof I], undefined>) => O
    ) {
      const ret = {} as { [K in keyof I]: O };
      let k: keyof I;
      for (k in target) {
        const tk = target[k];
        if (typeof tk !== "undefined") {
          ret[k] = callback(tk as Exclude<I[keyof I], undefined>);
        }
      }
      return ret;
    }
    

    Given that signature, you can now use objectMap() like this:

    interface A {
      a?: number;
      b: number;
      c?: number;
    }
    const a: A = { a: 1, b: 2 };
    const result = objectMap(a, x => x.toFixed());
    // const result: {a?: string | undefined, b: string, c?: string | undefined }
    console.log(result); // {a: "1", b: "2"}
    

    So in this case, the A interface has optional numeric properties "a" and "c", and a required numeric property "b". The callback is of type (x: number)=>string, and the returned result type has the same optional and required property keys, but the values are string types. (Note that the type {a?: string | undefined, b: string, c?: string | undefined} is identical to the type {a?: string, b: string, c?: string}) which is about the best the compiler can do if all it knows about the a value at compile time is that it is assignable to A.

    Okay, hope that helps; good luck!

    Link to code