Ely
Ely

Reputation: 81

Typing keys and values in a dictionary to co-vary

This is probably a simple issue, but I haven't been able to find a solution.

I have a dictionary that holds values of different type. I want to type the dictionary in a manner such that typescript knows the type of value based on type of the key.

Here is the setup:

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 

I can check the type of value before I access b, but I feel like there is an elegant solution using generics. Something along the lines of

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

How would I implement something like this?

Thank you!

Upvotes: 2

Views: 124

Answers (1)

jcalz
jcalz

Reputation: 330456

If you knew at compile time how to identify which ids correspond to Shops and which ones correspond to ShopOwners, then things would be easier. For example, if the situation where Shop ids began with the string "shop_" and ShopOwner ids began with the string "owner_", then you could use template literal types to keep track of this distinction:

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

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

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

Then the dataNormalized table would be an intersection of Record object types which match up ShopId keys to Shop values, and ShopOwnerId keys to ShopOwner values:

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

And your getEntity() could be easily written as

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

And you'd get runtime and compile time behavior that works:

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}`'

Unfortunately, you have no idea which ids correspond to Shops and which ones correspond to ShopOwners... or at least the compiler has no idea. They are both strings and the compiler can't easily distinguish them.


In this case, the best you can do is to pretend that there's some such distinction. One technique that's used in this case is to "brand" a primitive type like string with a tag that says what it's used for:

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

So here a ShopId is, supposedly, a string which also has a __type property of literal type "Shop". And a ShopOwnerId is similar except its __type property is "ShopOwner". If that were really true, you could just inspect the __type property and behave accordingly. But of course, at runtime, you just have strings and you can't add properties to them. No, this is just a convenient lie we're telling to the compiler.

You have similar interface definitions,

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

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

And your dataNormalized is still of type Record<ShopId, Shop> & Record<ShopOwnerId, ShopOwner>, but now you have to assert that your ids are of the appropriate type, since the compiler cannot verify it:

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

And although getEntity() can be implemented the same way, you'll find it hard to use directly:

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

You could just go ahead and keep asserting those keys are of the right type, but you run the risk of asserting the wrong thing:

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

In order to try to claw some safety back, you could write some functions that do runtime validation of your keys before promoting them to ShopId or ShopOwnerId. Say a general custom type guard function along with some helpers for each specific id type:

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

Now you can safely write your code:

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

Well, you'll still get runtime errors when you make a mistake, but at least they will be caught as early as possible:

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

Honestly, it's not great. Ideally you'd refactor so your ids could be told apart at compile time. But in the absence of that, you could at least use branded primitives to help you keep things straight, even though they are less of a guarantee since there is no such distinction at runtime.

Playground link to code

Upvotes: 1

Related Questions