typescript - Näppäinten ja arvojen kirjoittaminen sanakirjassa korreloimaan


Tämä on luultavasti yksinkertainen ongelma, mutta en ole onnistunut löytämään ratkaisua.

Minulla on sanakirja, joka sisältää eri tyyppisiä arvoja. Haluan tyypittää sanakirjan siten, että TypeScript tietää arvon tyypin avaimen tyypin perusteella.

Tässä on asennus:

type Id = string;

interface Shop {
  id: Id;
  a: ... // `a` is a property unique to Shop
}

interface ShopOwner {
  id: Id;
  b: ... // `b` is a property unique to ShopOwner
}

type Objects = {[key: Id]: Shop | ShopOwner }

// when I call the dictionary with a key that is guaranteed to return ShopOwner,
// typescript throws an error saying returned value can be either object ShopOwner or Shop
// and Shop doesn't have property b 
const shopOwner = dictionary[key].b 

Voin tarkistaa arvon tyypin ennen kuin pääsen käsiksi b:hen, mutta tuntuu siltä, että genericsin avulla olisi elegantti ratkaisu. Jotain tämän suuntaista

type Id<T is either Shop or ShopOwner> = ?
type Objects<T is either Shop or ShopOwner> = {[key: Id<T>]: T}

Miten voisin toteuttaa jotain tällaista?

Kiitos!

Vastaus #1

Jos tietäisit käännösaikana, kuinka tunnistaa, mitkä id‑arvot vastaavat Shop-kohteita ja mitkä ShopOwner-kohteita, asiat olisivat helpompia. Esimerkiksi, jos tilanne olisi sellainen, että Shop-id:t alkaisivat merkkijonolla "shop_" ja ShopOwner-id:t alkaisivat merkkijonolla "owner_", silloin voisit käyttää mallipohjaisia kirjainyhdistelmätyyppejä tämän eron seuraamiseen:

type ShopId = `shop_${string}`;
type ShopOwnerId = `owner_${string}`;

interface ShopOwner {
  id: ShopOwnerId;
  name: string;
  shop: Shop;
}

interface Shop {
  id: ShopId;
  name: string;
  address: string;
}

Sitten dataNormalized-taulukko olisi Record-oliotyyppien leikkauspiste, jotka yhdistävät ShopId-avaimet Shop-arvoihin ja ShopOwnerId-avaimet ShopOwner-arvoihin:

declare const dataNomalized: Record<ShopId, Shop> & Record<ShopOwnerId, ShopOwner>;

Ja sinun getEntity() voidaan helposti kirjoittaa näin

function getEntity<I extends ShopId | ShopOwnerId>(id: I) {
  const ret = dataNomalized[id];
  if (!ret) throw new Error("no entry at '" + id + "'")
  return ret;
}

Ja saisit suoritusaika- ja käännösaikakäyttäytymisen, joka toimii:

console.log(getEntity("shop_b").address.toUpperCase());
console.log(getEntity("owner_a").shop.address.toUpperCase());
getEntity("who knows") // compiler error, 
// 'string' is not assingbale to '`shop_${string}` | `owner_${string}`'

Valitettavasti sinulla ei ole aavistustakaan, mitkä ids vastaavat Shopeja ja mitkä vastaavat ShopOwnereja... tai ainakin kääntäjällä ei ole aavistustakaan. Ne ovat molemmat stringejä, eikä kääntäjä pysty helposti erottamaan niitä.


Tässä tapauksessa paras, mitä voit tehdä, on tekeytyä siltä, että tällainen erottelu on olemassa. Yksi tekniikka, jota käytetään tässä tapauksessa, on "merkitä" primitiivinen tyyppi, kuten string, tunnisteella, joka kertoo, mihin sitä käytetään:

type Id<K extends string> = string & { __type: K }
type ShopId = Id<"Shop">;
type ShopOwnerId = Id<"ShopOwner">;

Joten tässä ShopId on ilmeisesti string, jolla on myös __type-ominaisuus, jonka konkreettinen tyyppi on "Shop". Ja ShopOwnerId on samanlainen, paitsi että sen __type-ominaisuus on "ShopOwner". Jos se todella olisi totta, voisit vain tarkastella __type-ominaisuutta ja toimia sen mukaisesti. Mutta tietenkin ajon aikana sinulla on vain string-arvoja etkä voi lisätä niihin ominaisuuksia. Ei, tämä on vain kätevä valhe, jota kerromme kääntäjälle.

Sinulla on samankaltaiset käyttöliittymämäärittelyt,

interface ShopOwner {
  id: ShopOwnerId;
  name: string;
  shop: Shop;
}

interface Shop {
  id: ShopId;
  name: string;
  address: string;
}

Ja sinun dataNormalized on edelleen tyyppiä Record<ShopId, Shop> & Record<ShopOwnerId, ShopOwner>, mutta nyt sinun on varmistettava, että idt ovat sopivaa tyyppiä, koska kääntäjä ei voi tarkistaa sitä:

const owner = {
  id: "a",
  name: "Peter",
  shop: {
    id: "b",
    name: "Peters's Shop",
    address: "123 Main St"
  }
} as ShopOwner; // assert

const dataNomalized = {
  a: owner,
  b: owner.shop,
} as Record<ShopId, Shop> & Record<ShopOwnerId, ShopOwner>; // assert

Ja vaikka getEntity() voidaan toteuttaa samalla tavalla, huomaat sen olevan vaikea käyttää suoraan:

getEntity("a") // compiler error!
getEntity("b") // compiler error!
//Argument of type 'string' is not assignable to parameter of type 'ShopId | ShopOwnerId'.

Voisit vain jatkaa ja väittää, että nuo avaimet ovat oikeantyyppisiä, mutta riskeeraat väittävän väärää asiaa:

getEntity("a" as ShopId).address.toUpperCase(); // okay for the compiler, but
// runtime error: 💥 getEntity(...).address is undefined 

Yrittääksesi saada jonkin verran turvallisuutta takaisin, voisit kirjoittaa joitakin funktioita, jotka tekevät ajonaikaista validointia avaimillesi ennen kuin muunnet ne ShopId tai ShopOwnerId -tyypeiksi. Esimerkiksi yleinen mukautettu tyyppisuojafunktio yhdessä joidenkin apufunktioiden kanssa jokaiselle tietylle tunnustyypille:

function isValidId<K extends string>(x: string, type: K): x is Id<K> {
  // do any runtime validation you want to do here, if you care
  if (!(x in dataNomalized)) return false;
  const checkKey: string | undefined = ({ Shop: "address", ShopOwner: "shop" } as any)[type];
  if (!checkKey) return false;
  if (!(checkKey in (dataNomalized as any)[x])) return false;
  return true;
}

function shopId(x: string): ShopId {
  if (!isValidId(x, "Shop")) throw new Error("Invalid shop id '" + x + "'");
  return x
}

function shopOwnerId(x: string): ShopOwnerId {
  if (!isValidId(x, "ShopOwner")) throw new Error("Invalid shop id '" + x + "'");
  return x;
}

Nyt voit turvallisesti kirjoittaa koodisi:

console.log(getEntity(shopId("b")).address.toUpperCase());
console.log(getEntity(shopOwnerId("a")).shop.address.toUpperCase());

No, saat silti ajoaikavirheitä, kun teet virheen, mutta ainakin ne havaitaan mahdollisimman varhaisessa vaiheessa:

getEntity(shopId("who knows")).address.toUpperCase(); // Invalid shop id 'who knows' 

Rehellisesti sanottuna, se ei ole mahtavaa. Idyylimielessä sinun pitäisi refaktoroida niin, että id:si voitaisiin erottaa käännösaikana. Mutta jos sitä ei ole, voit ainakin käyttää brändättyjä primitiivejä auttaaksesi pitämään asiat selvänä, vaikka ne tarjoavatkin vähemmän takeita, koska ajonaikana ei ole tällaista erottelua.

Leikkikenttälinkki koodiin