Search code examples
javascripttypescript

Function that returns object with same keys as the input object


I attempt to write a function fn() with the following properties:

  1. Takes a single argument x which is an object with optional keys "a" and "b" (for simplicity, each field may be numeric)
  2. It outputs a new object with the same keys as provided in the input object but each field is a specific class (a->A, b->B), i.e. the desired behavior is:
  • if I call fn({a: 1, b: 1}) I receive {a: A, b: B}
  • if I call fn({b:1}) I receive {b: B} and
  • if I call fn({}) I receive {}.

You can take the numeric values in the input object as initialization values for the classes in the output object. Also, new fields "c", "d", "e", ... may be added later to this function.

I struggle to set this up on the type level but also in the JS code. I have written the following code which raises Typescript errors:

  • Type 'string' is not assignable to type 'keyof OutputObject'.ts(2344)
  • Type 'Partial' is not assignable to type 'Pick<OutputObject, K>'.ts(2322)
class A {}
class B {} 

interface InputObject {
a: number,
b: number
}

interface OutputObject {
a: A,
b: B
}

// My failed attempt at writing fn()

const fn = <
    TObj extends Partial<InputObject>,
    K extends keyof TObj
>(x: TObj): Pick<OutputObject, K> => {

    let returnValue: Partial<OutputObject> = {}

    if (x.a) {
        returnValue.a = new A()
    }

    if (x.b) {
        returnValue.b = new B()
    }

    return returnValue
}

Solution

  • TypeScript won't be able to follow the logic inside the implementation of fn() to verify that it conforms to the generic mapped type relationship of Picking the same keys from the OutputObject and InputObject types. You'll essentially need type assertions to loosen the type checking enough to proceed. That means you have to be careful that your code acts as advertised, since the compiler can't really check it.

    Here's a possible approach:

    const fn = <K extends keyof InputObject = never>(
        x: Pick<InputObject, K> & Partial<InputObject>
    ): Pick<OutputObject, K> => {    
        let returnValue = {} as
            Pick<OutputObject, K> & Partial<OutputObject>;
        if (x.a) { returnValue.a = new A() }    
        if (x.b) { returnValue.b = new B() }    
        return returnValue;
    }
    

    The important pieces of this are:

    • the function is only generic in K, the keys present in the x input type. This allows the compiler to more easily infer K, especially since we don't want any extra keys which could happen if x were a subtype of Pick<InputObject, K>.

    • if you pass in {}, the compiler fails to infer K properly, and would normally fall back to keyof InputObject, which is exactly the opposite of what we want. We want the never type, meaning the absence of any keys. So there's a default type argument of never.

    • the input type is intersected with Partial<InputObject> so the compiler is sure inside the function that if a property exists with key a or b, then it is of the InputObject value type. That's needed because object types in TS are not sealed. The value {a: 1, b: "hello"} is technically of type Pick<InputObject, "a">, because you could write interface Foo extends Pick<InputObject, "a"> {b: string} and use a Foo. We want to prevent such possibilities.

    • the type assertion for returnValue is a lie at the time it is made, since {} is unlikely to be of type Pick<OutputObject, K>. But the idea is that it will be true by the time the function returns.

    • the asserted type is also intersected with Partial<OutputObject> for the same reason as the aformentioned intersection; this lets the compiler know that it's okay to index into returnValue with a and b.

    Okay, let's test it:

    const v = fn({ a: 1 });
    // const v: Pick<OutputObject, "a">
    const w = fn({});
    // const w: Pick<OutputObject, never>
    const x = fn({ b: 2 });
    // const x: Pick<OutputObject, "b">
    const y = fn({ a: 1, b: 2 })
    // const y: Pick<OutputObject, "a" | "b">
    

    Looks good. The output types are what we expect them to be.

    Playground link to code