Stark typa en karta av objektets nycklar/värden till ett objekt med samma nycklar men olika värden i TypeScript


Jag behöver ofta ta ett objekt och skapa ett nytt objekt med samma nycklar men med värden som är en viss avbildning från KVP till någon T. JavaScript för detta är enkelt:

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);

Jag försöker starkt typa y i exemplet ovan. Bygger vidare på Att inferera formen på resultatet av Object.fromEntries() i TypeScript, jag försökte:

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; }
 */

Muspekaren över .map visar att T inte härleds; det är av typen unknown.

Det som förbryllar mig är att även om jag manuellt anger att T är string, är y fortfarande { 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; }
 */

Några idéer?

TS Lekplats

Svar #1

Jag kommer bara att svara så fullständigt jag kan och hoppas att ditt användningsfall finns med någonstans. Först ska jag försöka bedöma de specifika problem du stötte på:

  • Det som orsakade fel även när man manuellt angav sina typer var att använda Extract<T, U> för att försöka dra ut posten som motsvarar en viss nyckel. Extract<T, U> delar upp en union T i medlemmar och kontrollerar att varje medlem är tilldelningsbar till U. Om T är ["foo" | "bar", string], och om U är ["foo", any], då kommer Extract<T, U> att bli never eftersom "foo" | "bar" inte är tilldelningsbar till "foo". Det är tvärtom. Så kanske vill du ha en ExtractSupertype<T, U> som behåller endast de element av T-unionen till vilka U är tilldelningsbar. Du kan skriva det så här:

    type ExtractSupertype<T, U> = T extends any ? [U] extends [T] ? T : never : never;
    

    Om du använder detta istället för Extract och tar bort readonly (eftersom any[] är en undertyp av readonly any[]), kan du få något att fungera. t.ex.:

    ExtractSupertype<E[][number], [K, any]>[1]  
    
  • Att härleda fyra typparametrar från en funktion som bara tar två argument kommer sannolikt inte att gå bra. Du behöver egentligen inte härleda F som något som extends dess begränsning. Begränsningen är tillräckligt bra:

    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] };
    

De ändringarna borde få dina tidigare trasiga saker att fungera, men det finns massor av andra saker där inne som jag skulle rekommendera att ändra (eller åtminstone skulle vilja höra någon förklaring för):

  • Jag ser inte T som en användbar typparameter att försöka härleda.

  • Jag förstår inte poängen med E[][number] eller att obj är typat som Readonly<O> istället för bara O.

  • Jag förstår inte varför du begränsar E[0] till keyof O. Jag skulle tänka att antingen tillåta att nycklarna ändras, i så fall begränsa det bara till PropertyKey, eller inte tillåta att nycklarna ändras, i så fall acceptera inte en callback som returnerar nycklar överhuvudtaget.


Jag kommer att ge några alternativa implementationer för denna map()-funktion (jag kommer inte att lägga till den i Object-konstruktorn eftersom det generellt sett inte rekommenderas att monkeypatcha inbyggda objekt) och kanske fungerar en av dem för dig.

Det enklaste jag kan komma på är att låta callbacken bara returnera egenskapsvärdena och inte nycklarna. Vi vill ju inte stödja att ändra nycklarna, eller hur? Så detta kommer att göra det omöjligt att ändra nycklarna:

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 att detta beter sig som önskat i ditt exempel:

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 finns begränsningar här; den mest anmärkningsvärda är att utdataobjektet kommer att ha alla egenskaper av samma typ. Kompilatorn kommer inte och kan inte förstå att en viss återuppringningsfunktion ger olika utdatatyper för olika indataegenskaper. I fallet med en identitetsliknande funktion som återkallas kan du förvänta dig att implementationen gör rätt sak men att kompilatorn utvidgar den returnerade objekttypen till något som liknar ett register:

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;
} */

Personligen ser jag inte detta som ett stort problem; uppenbarligen kommer du inte att skicka identitetsfunktionen till map() (vad är poängen?) och alla andra funktioner som gör något nytt (t.ex. omvandlar number-värden till string) kommer inte att få sina effekter magiskt härledda av kompilatorn:

const nope = mapValues(obj, e => (typeof e[1] === "number" ? e[1] + "" : e[1]))
/* const nope: {
    a: string | boolean;
    b: string | boolean;
    c: string | boolean;
} */

Även om du går igenom besväret att deklarera callbacken som en generisk funktion som gör rätt sak, kan kompilatorn inte utföra högre ordningens resonemang i typ-systemet för att få returtypen av f som något som ändras beroende på dess argumenttyp. Det finns några GitHub-ärenden som har efterfrågat det (jag tror att microsoft/TypeScript#40179 är det senaste öppna ärendet om detta) men inget finns där än så länge. Så även med mer ansträngning får du vidgade 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;
} */

Jaja.


Om du verkligen vill stödja att transformera nycklarna såväl som värdena, skulle jag föreslå att låta nycklarna bli vilken PropertyKey som helst, och du kommer förmodligen behöva göra utdata Partial eftersom kompilatorn inte kan garantera att varje möjlig utdata-nyckel faktiskt är med i utdata. Här är mitt försö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;
}

Det här är så nära jag kan komma din ursprungliga kod. Det gör ungefär samma sak som din med det ursprungliga exemplet, förutom de valfria nycklarna:

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 behöver de där valfria nycklarna dock; eftersom stödningsnycklar ändras betyder det att du inte kan veta om varje möjlig utgångsnyckel faktiskt kommer att produceras:

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 hur needsToBePartial vid körning saknar c-egenskapen.

Och nu för fler begränsningar. En identitetsfunktion gör samma utvidgning som tidigare, till en postliknande typ:

const widened2 = mapEntries(obj, e => e);
/* const widened: {
    a?: string | number | boolean;
    b?: string | number | boolean;
    c?: string | number | boolean;
} */

Du kan göra callbacken till en generisk och faktiskt få en mer exakt typ, men det är bara för att union-of-entries inmatningstypen blir samma union-of-entries utgångstyp:

const widened3 = mapEntries(obj, <T,>(e: T) => e);
/* const widened3: {
    a?: number | undefined;
    b?: string | undefined;
    c?: boolean | undefined;
} */

Allt du gör som är mer nytt än en identitetsfunktion lider av samma problem som tidigare: det är svårt eller omöjligt att tala om för kompilatorn tillräckligt mycket om vad callback-funktionen gör för att få den att sluta sig till vilken möjlig utgång som hör till vilken möjlig nyckel, och du får en återigen ett registerliknande objekt:

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å även om denna version använder en Extract-liknande returtyp för att försöka matcha varje inmatningspost med en motsvarande utmatningspost, är den egentligen inte särskilt användbar i praktiken.


Playground-länk till kod

Relaterade artiklar