Sterkt typ en map av objekt-nøkler/verdier til et objekt med de samme nøklene men forskjellige verdier i TypeScript
Jeg trenger ofte å ta et objekt og produsere et nytt objekt med de samme nøklene, men med verdier som er en slags kartlegging fra KVP til noen T. JavaScript for dette er enkel:
Object.map = (obj, fn) => Object.fromEntries(Object.entries(obj).map(fn));
// example:
const x = { foo: true, bar: false };
const y = Object.map(x, ([key, value]) => [key, `${key} is ${value}.`]);
console.log(y);Jeg prøver å sterkt typisere y i eksemplet ovenfor. Bygger videre på Infer form av resultatet av Object.fromEntries() i TypeScript, prøvde jeg:
type obj = Record<PropertyKey, any>;
type ObjectEntries<O extends obj> = {
[key in keyof O]: [key, O[key]];
}[keyof O];
declare interface ObjectConstructor {
/**
* More intelligent declaration for `Object.fromEntries` that determines the shape of the result, if it can.
* @param entries Array of entries.
*/
fromEntries<
P extends PropertyKey,
A extends ReadonlyArray<readonly [P, any]>
>(entries: A): { [K in A[number][0]]: Extract<A[number], readonly [K, any]>[1] };
map<
O extends obj,
T,
E extends readonly [keyof O, T],
F extends (entry: ObjectEntries<O>, idx: number) => E
>(obj: Readonly<O>, fn: F): { [K in E[][number][0]]: Extract<E[][number], readonly [K, any]>[1] };
}
Object.map = (obj, fn) => Object.fromEntries(Object.entries(obj).map(fn));
const x = { foo: true, bar: false } as const;
const y = Object.map(x, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);
type XEntries = ObjectEntries<typeof x>;
type Y = typeof y;
/**
* XEntries = ['foo', true] | ['bar', false]
* Y = { foo: never; bar: never; }
*/
Mus over .map viser at T ikke blir utledet; det er av typen unknown.
Det som forvirrer meg, er at selv om jeg manuelt spesifiserer at T er string, er y fortsatt { foo: never; bar: never; }.
const z =
Object.map<
typeof x,
string,
readonly [keyof typeof x, string],
(entry: ObjectEntries<typeof x>, idx: number) => [keyof typeof x, string]
>(x, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);
type Z = typeof z;
/**
* Z = { foo: never; bar: never; }
*/
Noen ideer?
TS-lekeplass
Svar #1
Jeg skal bare svare så fullt jeg kan og håpe at brukstilfellet ditt er der et sted. Først skal jeg prøve å vurdere de spesifikke problemene du støtte på:
Tingen som forårsaket feil selv når du manuelt spesifiserte typene dine, var å bruke
Extract<T, U>for å prøve å hente ut oppføringen som tilsvarer en bestemt nøkkel.Extract<T, U>deler opp en unionTi medlemmer, og sjekker at hvert av dem kan tilordnes tilU. HvisTer["foo" | "bar", string], og hvisUer["foo", any], vilExtract<T, U>blineverfordi"foo" | "bar"ikke kan tilordnes"foo". Det er motsatt vei. Så kanskje du vil ha enExtractSupertype<T, U>som beholder bare de elementene avT-unionen til hvilkeUkan tilordnes. Du kan skrive det slik:type ExtractSupertype<T, U> = T extends any ? [U] extends [T] ? T : never : never;Hvis du bruker dette i stedet for
Extractog fjernerreadonly(sidenany[]er en underklasse avreadonly any[]), kan du få noe til å fungere. f.eks.:ExtractSupertype<E[][number], [K, any]>[1]Å utlede fire typeparametere fra en funksjon som kun tar to argumenter, vil sannsynligvis ikke gå bra. Du trenger egentlig ikke å utlede
Fsom noe somextendssin begrensning. Begrensningen er god nok:map< O extends obj, T, E extends readonly [keyof O, T], >(obj: Readonly<O>, fn: (entry: ObjectEntries<O>, idx: number) => E): { [K in E[][number][0]]: ExtractSupertype<E[][number], [K, any]>[1] };
Disse endringene bør få det som tidligere var ødelagt til å fungere, men det er mye annet der inne jeg vil anbefale å endre (eller i det minste gjerne ville høre en forklaring på):
Jeg ser ikke på
Tsom en nyttig typeparameter å forsøke å utlede.Jeg ser ikke poenget med
E[][number]eller atobjer typet somReadonly<O>i stedet for bareO.Jeg forstår ikke hvorfor du begrenser
E[0]tilkeyof O. Jeg ville tro enten at nøklene kan endres, i så fall bare begrens det tilPropertyKey, eller ikke la nøklene endres, i så fall ikke aksepter en callback som returnerer nøkler i det hele tatt.
Jeg skal gi noen alternative implementeringer for denne map()-funksjonen (jeg kommer ikke til å legge den til i Object-konstruktøren siden monkey patching av innebygde objekter vanligvis frarådes), og kanskje en av dem vil fungere for deg.
Det enkleste mulige jeg kan tenke på er å la callbacken bare returnere egenskapsverdiene og ikke nøklene. Vi ønsker vel ikke å støtte endring av nøklene, ikke sant? Så dette vil gjøre det umulig å endre nøklene:
type ObjectEntries<T> = { [K in keyof T]: readonly [K, T[K]] }[keyof T];
function mapValues<T extends object, V>(
obj: T,
f: (value: ObjectEntries<T>) => V
): { [K in keyof T]: V } {
return Object.fromEntries(
(Object.entries(obj) as [keyof T, any][]).map(e => [e[0], f(e)])
) as Record<keyof T, V>;
}
Du kan se at dette oppfører seg som ønsket i ditt eksempelt tilfelle:
const example = mapValues({ foo: true, bar: false }, entry => `${entry[0]} is ${entry[1]}.`);
/* const example: {
foo: string;
bar: string;
} */
console.log(example);
/* {
"foo": "foo is true.",
"bar": "bar is false."
} */
Det finnes begrensninger her; den mest bemerkelsesverdige er at utdataobjektet vil ha alle egenskaper av samme type. Kompilatoren vil ikke og kan ikke forstå at en bestemt callback-funksjon produserer forskjellige utdata-typer for forskjellige inngangsegenskaper. I tilfelle av en identitetslik funksjons-callback, kan du forvente at implementeringen gjør det rette, men at kompilatoren utvider den returnerte objekttypen til noe som ligner på en oppføring:
const widened = mapValues({a: 1, b: "two", c: true}, e => e[1]);
/* const widened: {
a: string | number | boolean;
b: string | number | boolean;
c: string | number | boolean;
} */
Personlig ser jeg ikke på dette som et stort problem; åpenbart kommer du ikke til å sende identitetsfunksjonen til map() (hva er poenget?) og enhver annen funksjon som gjør noe nytt (for eksempel konverterer number-verdier til string) vil ikke få sine effekter magisk utledet av kompilatoren:
const nope = mapValues(obj, e => (typeof e[1] === "number" ? e[1] + "" : e[1]))
/* const nope: {
a: string | boolean;
b: string | boolean;
c: string | boolean;
} */
Selv om du går gjennom bryet med å erklære tilbakekallet som en generisk funksjon som gjør det riktige, kan ikke kompilatoren utføre høyereordensresonnement i typesystemet for å få returtypen til f som noe som endres avhengig av argumenttypen. Det finnes noen GitHub-issues som har etterspurt dette (jeg tror microsoft/TypeScript#40179 er den nyeste åpne saken om dette), men så langt finnes det ingenting. Så selv med mer innsats får du utvidede typer:
const notBetter = mapValues(obj, <T extends ObjectEntries<typeof obj>>(e: T) =>
(typeof e[1] === "number" ? e[1] + "" : e[1]) as
T[1] extends number ? string : Exclude<T[1], number>)
/* const notBetter: {
a: string | boolean;
b: string | boolean;
c: string | boolean;
} */
Å vel.
Hvis du faktisk ønsker å støtte transformering av nøklene så vel som verdiene, vil jeg foreslå å tillate at nøklene kan bli hvilken som helst PropertyKey, og du vil sannsynligvis måtte gjøre utdataene Partial fordi kompilatoren ikke kan garantere at hver mulig utgangsnøkkel faktisk blir generert. Her er mitt forsøk:
type GetValue<T extends readonly [PropertyKey, any], K extends T[0]> =
T extends any ? K extends T[0] ? T[1] : never : never;
function mapEntries<T extends object, R extends readonly [PropertyKey, any]>(
obj: T,
f: (value: ObjectEntries<T>, idx: number, array: ObjectEntries<T>[]) => R
): { [K in R[0]]?: GetValue<R, K> } {
return Object.fromEntries(Object.entries(obj).map(f as any)) as any;
}
Dette er så nærme jeg kan komme din opprinnelige kode. Den gjør omtrent det samme som din på det opprinnelige eksemplet, bortsett fra de valgfrie nøklene:
const example2 = mapEntries({ foo: true, bar: false }, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);
/* const example2: {
foo?: string | undefined;
bar?: string | undefined;
} */
console.log(example2);
/* {
"foo": "foo is true.",
"bar": "bar is false."
} */
Du trenger de valgfrie tastene, skjønt; at støttetaster endres betyr at du ikke kan si om hver mulig utgangstast faktisk vil bli produsert:
const needsToBePartial = mapEntries(obj, e => e[0].charCodeAt(0) % 3 !== 0 ? e : ["oops", 123] as const);
/* const needsToBePartial: {
a?: number | undefined;
b?: string | undefined;
c?: boolean | undefined;
oops?: 123 | undefined;
} */
console.log(needsToBePartial);
/* {
"a": 1,
"b": "two",
"oops": 123
} */
Se hvordan needsToBePartial under kjøring mangler c-egenskapen.
Og nå for flere begrensninger. En identitetsfunksjon gjør det samme utvidende trikset som før, på en post-lignende type:
const widened2 = mapEntries(obj, e => e);
/* const widened: {
a?: string | number | boolean;
b?: string | number | boolean;
c?: string | number | boolean;
} */
Du kan promotere tilbakeringingen til en generisk og faktisk få en strammere type, men det er bare fordi inntasttypen med en union av oppføringer blir den samme union-av-oppføringer-utgangstypen:
const widened3 = mapEntries(obj, <T,>(e: T) => e);
/* const widened3: {
a?: number | undefined;
b?: string | undefined;
c?: boolean | undefined;
} */
Alt du gjør som er mer nytt enn en identitetsfunksjon lider av det samme problemet som før: det er vanskelig eller umulig å fortelle kompilatoren nok om hva callback-funksjonen gjør for å få den til å slutte seg til hvilken mulig utgang som går med hvilken mulig nøkkel, og du får en oppføringslignende ting igjen:
const moreInterestingButNope = mapEntries(
obj, (e) => [({ a: "AYY", b: "BEE", c: "CEE" } as const)[e[0]], e[0]])
/* const moreInterestingButNope: {
AYY?: "a" | "b" | "c" | undefined;
BEE?: "a" | "b" | "c" | undefined;
CEE?: "a" | "b" | "c" | undefined;
} */
Så selv om denne versjonen bruker en Extract-lignende returtype for å prøve å matche hver inngangsoppføring med en tilsvarende utgangsoppføring, er den egentlig ikke veldig nyttig i praksis.
Lenke til kode på lekeplassen
