typescript - Hur man korrekt typar en Object.assign-liknande generisk funktion?
Jag försöker skapa en "assign default"-funktion i TypeScript, där den loopar igenom nycklarna i source, och om värdet med samma nyckel är nullish i target, så kommer den använda värdet från source istället. Här är mitt försök:
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'.
}
Jag lånade generics från hur Object.assign är typat i TypeScript:
ObjectConstructor.assign<T, U>(target: T, source: U): T & U;
Men jag kunde inte hitta ett sätt att komma runt dessa fel.
Lekplats
Svar #1
Jag lånade generics från hur Object.assign är typat
Det finns en stor skillnad mellan typdefinitioner och implementering. Kompilatorn är nöjd med : T & U från standardbiblioteket eftersom det inte finns någon motstridig information. I din implementering är dock typen av target bara T, medan signaturen förväntar sig T & U, därmed kompilatorfelet.
Kompilatorn är inte en körningstid, så även om den gör viss kontrollflödesanalys kan den inte härleda att forEach muterar target. Det finns dock sätt att göra kompilatorn medveten om det. Den enklaste är att påstå typen av det returnerade värdet antingen som any (för att inaktivera typkontrollen för det) eller 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;
};
Lekplats
Det finns dock ett annat problem med returtypen för assignDefault: T & U är en snittning, vilket inte är vad du vill. För att korrekt typa "om nullish, tilldela" behöver man en mappad typ med vissa villkorliga typer och extends-kontroller för att avgöra om en egenskap kan tilldelas.
En sådan typ skulle kunna se ut ungefär så här:
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 }
>;
Lekplats
Resten är bara en fråga om att ange returtypen för funktionen:
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 }
Lekplats
Slutligen finns det ett fel till att hantera:
Fel: Typen 'U[keyof U]' kan inte tilldelas typen 'T[keyof T]'.
Kompilatorn kontrollerar om U[keyof U] (source[key]) kan tilldelas T[keyof T] (target[key]), men den har ingen information om hur T och U är relaterade. Allt den vet är att båda är generiska typparametrar och därför kan vara vad som helst. Enligt signaturen är det inte ens garanterat att någon av dem är ett objekt (du vet det men inte kompilatorn):
assignDefault(true, false); // no objection from the compiler
Eftersom du i detta fall vet mer än kompilatorn, är det okej att använda as unknown as T[keyof T] för att påstå att source[key] faktiskt är 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 }
Lekplats
Det är många påståenden, men kan vi göra bättre? Ja, om vi avstår från Object.keys till förmån för den goda gamla for...in-slingan på grund av egenheten att key är typad som string (det finns en god anledning till det, men ändå). Att använda for...in (med lämpligt skydd) låter oss ta bort as keyof-påståendena:
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>;
};
Lekplats
Observera användningen av Object.assign(target, key, { [key]: source[key] }); för att undvika tilldelningsfelet (man kan också använda Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)!);).
Svar #2
Det här är vad jag kom på.
Några saker att notera:
typeof kommer aldrig att returnera "null" se: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof
andra saken är att typen T & U förväntar sig en kombination av både målet och källan. Du kan uppnå det ganska enkelt genom att använda spridningsoperatorn för att slå ihop dem som visas i mitt exempel.
[REDIGERA] Istället för att vara begränsad av den typ du krävde, har jag helt ändrat funktionen för att passa bättre med din ursprungliga avsikt.
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);
Observera att denna funktion egentligen bara fungerar med objekt som har nyckel-värdepar.
