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.
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 return
ed 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;
};
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 }
>;
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 }
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 }
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>;
};
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.