Kan jag skapa en nyckel-värde-ordbok i TypeScript som är typesäker för varje nyckel-värde-par?


Den här frågan är ganska invecklad och kanske bäst att närma sig genom att utforska ett rudimentärt statssystems, så följ med mig här en minut. Antar att jag har den här state-klassen:

class AccountState {
  public id: string;
  public displayName: string;
  public score: number;
}

Från jcalz:s arbete här vet jag att jag kan bygga en funktion som refererar till vilken AccountState-egenskap som helst på ett typ-säkert sätt—jag kan ta ett egenskapsnamn och värde, och tillämpa egenskapens egen typbegränsning på det värdet med hjälp av generics, vilket är ganska imponerande:

class Store {
  state = new AccountState();

  mutate<K extends keyof AccountState>(property: K, value: AccountState[K]): void {
    this.state[property] = value;
  }
}

const store = new Store();
store.mutate('displayName', 'Joseph Joestar'); // ok
store.mutate('displayName', 5); // not ok: surfaces the below typescript error
// ts(2345) Argument of type 'number' is not assignable to parameter of type 'string'.

Genom att använda ValueOf<T> i jcalz:s svar, kan jag också modellera en slags typ-säker nyckel-värde-dictionary. Det skulle nog vara enklast för mig att visa dig hur det fungerar, samt dess brister, i praktiken:

type ValueOf<T> = T[keyof T];

class Store {
  state = new AccountState();

  mutateMany(updates: { [key in keyof AccountState]?: ValueOf<AccountState> }): void {
    Object.keys(updates).forEach(property => {
      const value = updates[property];
      (this.state[property] as any) = value;
    });
  }
}

const store = new Store();
store.mutateMany({ displayName: 'Joseph Joestar', score: 5 }); // ok
store.mutateMany({ displayName: 1000, score: 'oh no' }); // unfortunately, also ok
store.mutateMany({ score: true }); // not ok, surfaces the below error
// ts(2322) Type 'boolean' is not assignable to type 'ValueOf<AccountState>'.
// (if AccountState had a boolean property, this would be allowed)

Den andra mutateMany() är ett problem. Som du kan se kan jag kräva att nyckeln är någon egenskap hos AccountState. Jag kan också kräva att värdet motsvarar någon egenskap på AccountState, så det måste vara string | number. Det finns dock inget krav på att värdet motsvarar egenskapens faktiska typ.

Hur kan jag göra ordboken helt typ-säker, så att t.ex. { displayName: 'a', score: 1 } är tillåtet men { displayName: 2, score: 'b' } inte är det?

Jag har övervägt att deklarera ett gränssnitt AccountStateProperties som helt enkelt upprepar alla dessa egenskaper och deras värden, och sedan definiera mutateMany(updates: AccountStateProperties), men det skulle innebära mycket kodduplicering för mer invecklade state-objekt. Jag visste inte att jag kunde göra några av dessa saker förrän idag, och jag undrar om typningssystemet har något jag kan utnyttja här för att göra denna ordlista helt typ-säker utan det tillvägagångssättet.

Svar #1

I mutateMany-metoden, [key in keyof AccountState]?: ValueOf<AccountState>, säger du att för vilken key som helst kan typen av värdet vara vilken typ som helst som AccountState har. Du kan se detta demonstrerat om du försöker göra en uppdatering med något som inte finns i AccountState (som true).

Istället tror jag att du vill ha:

mutateMany(updates: { [key in keyof AccountState]?: AccountState[key] })

Detta säger att värdet vid key dessutom bör matcha typen av AccountState vid key och inte bara någon av typerna för värdena för AccountState.

[redigera: Om du tittar på det länkade svaret beskriver avsnittet som börjar med "För att säkerställa att nyckel-/värdeparet "matchar" korrekt i en funktion, bör du använda generiska typer samt uppslagstyper..." detta]

Svar #2

För mutateMany() tror jag att versionen som fungerar och inte kräver för mycket osäkerhet är att ha updates av typen Pick<AccountState, K>, där K är begränsat till att vara nycklar från AccountState, och där vi använder hjälptypen Pick<T, K>. Ett värde av typen Pick<AccountState, K> kommer att ha alla egenskaper från AccountState på nycklarna i K.

Så här kan man skriva det:

class Store {
    state = new AccountState();
    mutateMany<K extends keyof AccountState>(updates: Pick<AccountState, K>): void {
        (Object.keys(updates) as Array<K>).forEach(<P extends K>(property: P) => {
            this.state[property] = updates[property];
        });
    }
}

Det enda osäkra som pågår här är att vi påstår att Object.keys(updates) endast kommer att returnera nycklarna i K. Kompilatorn vet egentligen inte detta, eftersom objekttyper i TypeScript inte är slutna; det kan mycket väl vara så att updates har fler egenskaper än kompilatorn känner till. Se den här frågan och svaret för mer information.

Låt oss se hur det fungerar:

const store = new Store();
store.mutateMany({ displayName: 'Joseph Joestar', score: 5 }); // ok
store.mutateMany({ displayName: 1000, score: 'oh no' }); // error!
// --------------> ~~~~~~~~~~~  ----> ~~~~~
// number isn't string,         string isn't number

store.mutateMany({ displayName: "okay", randomProp: 123 });  // error!
// -----------------------------------> ~~~~~~~~~~~~~~~
// Object literal may only specify known properties

Ser bra ut. Felen dyker upp där vi förväntar oss dem.


Observera att problemet med extra nycklar i updates förhindras ovan om du skickar in en objektliteral, på grund av kontroller för överflödiga egenskaper. Men om du inte använder en ny objektliteral, är det möjligt att göra konstiga saker:

const badMutation = {
    displayName: "okay",
    randomProp: 123
}
const aliasedMutation: { displayName: string } = badMutation;
store.mutateMany(aliasedMutation); // no error here

Detta är osannolikt, så du kanske inte bryr dig. Om du bryr dig kan typ systemet i TypeScript egentligen inte stoppa det, så du skulle behöva skydda dig själv vid körning med någon extra kontroll som

        if (["id", "displayName", "score"].includes(property))
            this.state[property] = updates[property];

Lekplatslänk till kod

Relaterade artiklar