Reputation: 81
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
Reputation: 330456
If you knew at compile time how to identify which id
s correspond to Shop
s and which ones correspond to ShopOwner
s, 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 Shop
s and which ones correspond to ShopOwner
s... or at least the compiler has no idea. They are both string
s 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 string
s 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 id
s 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.
Upvotes: 1