typing - Hvordan utføre et typesikkert objektkart i Typescript?
Jeg prøver å skrive en funksjon i Typescript som mapper et objekt, samtidig som den garanterer at det beholder de samme nøklene. Jeg har prøvd ulike tilnærminger til dette, men har ikke funnet en måte som fungerer:
function mapObject1<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
return Object.fromEntries(
Object.entries(object)
.map(([key, value]): [K, B] => [key, mapper(value)]),
); // error!
// Type '{ [k: string]: B; }' is not assignable to type '{ [P in K]: B; }'
}
export function mapObject2<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
const result: { [P in K]?: B } = {};
(Object.keys(object) as K[]).forEach((key: K) => {
result[key] = mapper(object[key]);
});
return result; // error!
// Type '{ [P in K]?: B | undefined; }' is not assignable to type '{ [P in K]: B; }'
}
I mapObject1 fører bruk av Object.entries() og Object.fromEntries() til at typen på nøklene blir konvertert til string. I mapObject2 må nøklene til result være valgfrie siden den starter tom, noe som gjør at Typescript ikke kan gjenkjenne at alle de samme nøklene som i object er til stede. Hvordan bør jeg gå frem for å løse dette problemet?
Svar #1
Dessverre kan ikke TypeScript-kompilatoren verifisere at implementeringen er trygg av noen få grunner, og den mest pragmatiske veien videre er å ta spesiell hensyn til at implementeringen din er skrevet riktig, og deretter bruke typepåstander for bare å fortelle kompilatoren at verdiene har de typene du hevder de har:
function mapObject1<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
return Object.fromEntries(
Object.entries(object)
.map(([key, value]) => [key, mapper(value as A)]),
// assert --------------------------------> ^^^^^
) as { [P in K]: B }
//^^^^^^^^^^^^^^^^^^ <-- assert
}
function mapObject2<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
const result: { [P in K]?: B } = {};
(Object.keys(object) as K[]).forEach((key: K) => {
// assert -------> ^^^^^^ (you already did this)
result[key] = mapper(object[key]);
});
return result as { [P in K]: B };
// assert --> ^^^^^^^^^^^^^^^^^^
}
Alt det kompilerer fint.
Årsakene til at kompilatoren ikke klarer å følge logikken:
Type-definisjonene for
Object.entries() metoden](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries)ogObject.keys()metoden begrenser ikke nøklene tilobjecttil bare din generiske typeK. Objekttyper i TypeScript er ikke "forseglet", så objekter kan ha flere egenskaper enn kompilatoren vet om. Derfor returnerer kompilatoren barestringfor nøkkeltypen ogunknownfor verditypen. Se Hvorfor returnerer ikke Object.keys en keyof-type i TypeScript? for mer informasjon.Så noen av feilene du får, er at kompilatoren prøver å redde deg fra problemer som dette:
interface Foo { x: number, y: number, z: number } const obj = { x: Math.LN2, y: Math.PI, z: Math.E, other: "abc" }; const foo: Foo = obj; // this assignment is okay const oops = mapObject1(foo, num => num.toFixed(2)); /* const oops: { x: string; y: string; z: string; } */ // 💥 num.toFixed is not a function !!!Verdien
fooer av typenFoofordi det er greit at det finnes ekstra egenskaper. Men så støter du på en kjøretidsfeil inne i mapperingsfunksjonen din. I praksis er dette ofte en ganske uvanlig situasjon, så kanskje du er overbevist om at dette er en akseptabel risiko. Hvis det er tilfelle, bruk typepåstanden og gå videre. Hvis ikke, må du omskrive funksjonen din for å akseptere en eksplisitt liste over nøkler som skal transformeres. Men jeg anser det som utenfor omfanget av spørsmålet slik det er stilt.Selv om kompilatoren visste/trodde at
Object.keys(object)returnerte hver eneste nøkkel, finnes det ingen måte for den å forstå at det å iterere over disse nøklene og sette en egenskap i returobjektet vil gjøre at returobjektet blir oppgradert fra et delvis objekt til et fullstendig objekt. Å gjøre det er trygt (så lengeObject.keys()ikke går glipp av noen nødvendige egenskapsnøkler), men å verifisere dette ligger utenfor kompilatorens resonnementsevner. Se Hvordan kan jeg gå fra et Partial<T> til T uten casting i Typescript for mer detaljer. Dette er et flott sted å bare bruke en typepåstand og gå videre.
Lenke til kode på lekeplassen
Svar #2
For å omgå Typescripts begrensninger, kan du spesifisere returtypen til mapObject når du kaller den og kaste returverdien til any. Dette er selvfølgelig ikke 100 % trygt, men det lar deg bevare typene fullt ut når du mapper objekter.
function mapObject<R>(
object: object,
mapper: (val: any, key: string) => any,
): R {
return Object.fromEntries(
Object.entries(object).map(([key, value]) => [key, mapper(value, key)]),
) as any;
}
Du kan deretter kalle denne funksjonen som følger, for eksempel for å utelate en egenskap c fra hver verdi av et input-objekt:
const input = {
k1: {
b: 1,
c: 2,
},
k2: {
c: 3,
d: 4,
},
} as const;
const mapper = <T extends { c: number }>(value: T) => {
const { c, ...other } = value;
return other;
};
const result = mapObject<
{ [k in keyof typeof input]: ReturnType<typeof mapper<typeof input[k]>> }
>(input, mapper);
Til slutt vil typeof result være:
{
readonly k1: {
readonly b: 1;
};
readonly k2: {
readonly d: 4;
};
}
Den grunnleggende grunnen til at dette er nødvendig, er at du ikke kan sende en generell parameter til en generell parameter i Typescript, noe som er kjent som en høyere-kind type (se dette svaret).
