Home > Back-end >  How to properly type an Object.assign-like generic function?
How to properly type an Object.assign-like generic function?

Time:12-27

I am trying to create an "assign default" function in TypeScript, where it loops through the keys of the source, and if that value by the same key is nullish in the target, it will use the value from the source instead. Here's my attempt:

const assignDefault = <T, U>(target: T, source: U): T & U => {
  Object.keys(source).forEach(key => {
    // typecasted as Object.keys returns string[]
    const prop = target[key as keyof T]
    if (typeof prop === 'undefined' || prop === null) {
      // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
      target[key as keyof T] = source[key as keyof U] 
    }
  })
  return target // Error: Type 'T' is not assignable to type 'T & U'.
}

I borrowed the generics from how Object.assign is typed in TypeScript:

ObjectConstructor.assign<T, U>(target: T, source: U): T & U;

But I couldn't find a way to get around these errors.

Playground

CodePudding user response:

I borrowed the generics from how Object.assign is typed

There is a big difference between type definitions and implementation. The compiler is happy with the : T & U from the standard library because there is no conflicting information. In your implementation, however, the type of target is just T, whereas the signature expects T & U, hence the compiler error.

The compiler is not a runtime, so although it does some control flow analysis, it cannot infer that forEach mutates target. There are ways of making the compiler aware of that, though. The simplest one is asserting the type of the returned value either as any (to disable the type checker for it) or T & U:

const assignDefault = <T, U>(target: T, source: U): T & U => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
          // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
          target[key as keyof T] = source[key as keyof U] 
        }
    });
    return target as T & U;
};

Playground

However, there is another issue with the return type of assignDefault: T & U is an intersection, which is not what you want. To properly type "if nullish, assign", one needs a mapped type with some conditional types and extends checks to determine if a property can be assigned.

Such a type could look something like this:

type AssignIfNullish<T, U> = {
    [P in keyof T]:  
        T[P] extends null | undefined ? // is value a subtype of null|undefined?
        U[P & keyof U]: // take the value from U
        T[P] // preserve the original
};

// type Test = { a: "null"; b: "undefined"; c: 42; }
type Test = AssignIfNullish<
    { a: null,  b: undefined,  c: 42 }, 
    { a:"null", b:"undefined", c: 24 }
>;

Playground

The rest is just a matter of asserting the return type of the function:

const assignDefault = <T, U>(target: T, source: U) => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
          // Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.
          target[key as keyof T] = source[key as keyof U] 
        }
    });
    return target as AssignIfNullish<T, U>;
};

assignDefault({ a:1,b:null } as const,{ b:42 } as const); // { a:1, b:42 }

Playground

Finally, there is one more error to deal with:

Error: Type 'U[keyof U]' is not assignable to type 'T[keyof T]'.

The compiler checks if U[keyof U] (source[key]) is assignable to T[keyof T] (target[key]), but it does not have any information on how T and U are related. All it knows is that both are generic type parameters and thus can be anything. According to the signature, it is not even guaranteed that either is an object (you know that but not the compiler):

assignDefault(true, false); // no objection from the compiler

Since in this instance you know more than the compiler, it is ok to use as unknown as T[keyof T] to assert that source[key] is actually T[keyof T]:

const assignDefault = <
    T extends object,
    U extends object
>(target: T, source: U) => {
    Object.keys(source).forEach((key) => {
        const prop = target[key as keyof T]
        if (typeof prop === 'undefined' || prop === null) {
            target[key as keyof T] = source[key as keyof U] as unknown as T[keyof T];
        }
    });
    return target as AssignIfNullish<T, U>;
};

assignDefault(true, false); // error as expected
assignDefault({ a:null }, { a:42, b: "extra" }); // ok, { a:number }

Playground

That is a lot of assertions, though, can we do better? Yes, if we forgo the Object.keys in favor of the good old for...in loop because of the quirk that key is typed as string (there is a good reason for it, but still). Using for...in (with the appropriate guard) allows us to drop the as keyof assertions:

const assignDefault = <
    T extends Partial<{ [P in keyof U]: unknown }> & object,
    U extends Partial<{ [P in keyof T]: unknown }> & object
>(target: T, source: U) => {
    for (const key in source) {
        if (!Object.prototype.hasOwnProperty.call(source, key)) continue;

        const prop = target[key];

        if (typeof prop === 'undefined' || prop === null) {
            Object.assign(target, key, { [key]: source[key] });
        }
    }

    return target as AssignIfNullish<T, U>;
};

Playground

Notice the use of Object.assign(target, key, { [key]: source[key] }); to avoid the assignability error (one could also use Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)!);).

CodePudding user response:

This is what I came up with.

Couple things to note:

typeof will never return "null" see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof

second thing the type T & U expects a combination of both the target and source. You can achieve that fairly easily by using the spread operator to merge them as shown in my example.

[EDIT] Instead of being restricted by the type you required. I completed modified the function to fit more with your original intention.

const assignDefault = (target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> => {
    for (const key in source) {
        if (target[key] === null || typeof target[key] === 'undefined') {
            target[key] = source[key];
        }
    }
    return target;
}

const myTarget = {
    prop1: null,
    prop2: "some value",
    prop3: ["some", "other", "value"]
}

const mySource = {
    prop1: "hello world",
    prop2: "don't show this value",
    prop3: [],
    prop4: "a value not provided by target originally"
}

// expect
/**
 * {
 *  prop1: "hello world",
 *  prop2: "some value",
 *  prop3: ["some", "other", "value"],
 *  prop4: "a value not provided by target originally"
 * }
 */

const newObj = assignDefault(myTarget, mySource);

console.log(newObj);

Note that this function only really works with objects with key value pairs.

  • Related