Home > Mobile >  How to match the type of an object's value given its associated key in TypeScript?
How to match the type of an object's value given its associated key in TypeScript?

Time:09-17

I need to delete an item from an array in cache. Items can be of different types. When deleting from the cache we hint to TypeScript what type of item we're deleting, ItemA or ItemB. I can't figure out how to ensure that the value matches the type of the associated key provided.

Here is the delete operation:

type ItemA = { id: number; name: string; isPrivate: boolean };

// Should error because `id` should be a number but we're passing it a string
cacheDel<ItemA>(cacheStore, "arrayOfItemA", "id", "not-a-number");

Here is the full toy project:

type CacheStore = Record<string, unknown[]>;

const cacheGet = <T>(store: CacheStore, hash: keyof CacheStore) => {
  return store[hash] as T[];
};

const cachePut = <T>(store: CacheStore, hash: keyof CacheStore, array: T[]) => {
  return (store[hash] = array);
};

const cacheDel = <T>(store: CacheStore, hash: keyof CacheStore, key: keyof T, value: T[keyof T]) => {
  const data = cacheGet<T>(store, hash);
  const updatedData = [...data.filter((item) => item[key] !== value)];
  cachePut(store, hash, updatedData);
};

type ItemA = { id: number; name: string; isPrivate: boolean };
type ItemB = { token: string; label: string; counter: number };

const cacheStore = {
  arrayOfItemA: [
    { id: 1, name: "item A", isPrivate: true },
    { id: 2, name: "item AA", isPrivate: false },
  ],
  arrayOfItemB: [
    { token: "aaaaa", label: "hello" },
    { token: "bbbbb", label: "bye" },
  ],
};
cacheDel<ItemA>(cacheStore, "arrayOfItemA", "id", "not-a-number"); // Should error because not a number
cacheDel<ItemB>(cacheStore, "arrayOfItemB", "token", 1); // Should error because not a string

How can I make sure that the last two statements fail until the right value type is passed depending on the key provided?

CodePudding user response:

Since you said the cache is opaque to the compiler and it cannot know which array element types correspond with which keys, then for this example I'm just going to declare the cacheStore variable to be of type CacheStore so that I'm not tempted to give you more type inference than you can use:

type CacheStore = Record<string, unknown[]>;
declare const cacheStore: CacheStore;

If you want the compiler to keep track of the literal type of the value passed in as cacheDel()'s key parameter, then you need to make cacheDel() generic in that type. This means your function needs to be generic in two type parameters; the type T of the cached value in the array, and the type K extends keyof T of key.

Unfortunately we immediately run into a current limitation of TypeScript as of TS4.8. You are manually specifying the T type argument, but you would want the compiler to infer the K type argument. And TypeScript doesn't support partial type argument inference as requested in microsoft/TypeScript#26242. When you call a generic function with multiple type parameters, you either need to let the compiler infer all of them, or you manually specify some parameters in which case the compiler will not infer any of them. (No, generic parameter defaults do not give you inference.) So while it would be great if we could write something like this:

// ☠          DO NOT USE          ☠ // 
// ☠ THIS IS NOT VALID TYPESCRIPT ☠ //
const cacheDel = <T, K extends keyof T = infer>(
    store: CacheStore, // nope ------> ^^^^^^^
    hash: keyof CacheStore,
    key: K,
    value: T[K]
) => { };

There's just no direct support for it.

There are workarounds, but they all involve changing how you call cacheDel(), at least a little.


The most common one is currying, where you split a single multi-parameter generic function like <T, K>(k: K)=>void into a generic single-parameter function which returns another single-parameter generic function like <T,>() => <K,>(k: K) => void. Then you can manually specify the first generic type argument, and then you let the compiler infer the other one in the returned function. Here's how it looks for cacheDel():

const cacheDel = <T,>() => <K extends keyof T>(
    // currying --> ^^^^^^^^
    store: CacheStore,
    hash: keyof CacheStore,
    key: K,
    value: T[K]
) => {
    const data = cacheGet<T>(store, hash);
    const updatedData = [...data.filter((item) => item[key] !== value)];
    cachePut(store, hash, updatedData);
};

cacheDel<ItemA>()(cacheStore, "someA", "id", "not-a-number"); // error    
cacheDel<ItemA>()(cacheStore, "someA", "id", 1); // okay
cacheDel<ItemB>()(cacheStore, "someB", "token", 1); // error
cacheDel<ItemB>()(cacheStore, "someB", "token", "okay"); // okay

So you have to change how you call cacheDel by adding in an extra set of parentheses, but it has the effect you want.


Another workaround is to give the function a dummy parameter of the type you would normally specify, and then let inference work on the whole thing. So instead of <T, K>(k: K)=>void you have <T, K>(k: K, dummyT: T)=>void (you could put the dummy parameter first if you prefer; the position isn't important) This parameter is not needed by the function implementation, so you don't even have to pass in a real value of that type; you can pass in something random you can assert to be of your type. I usually use null! which is of type never but you can do whatever. Here's how it could look for cacheDel():

const cacheDel = <T, K extends keyof T>(
    store: CacheStore,
    hash: keyof CacheStore,
    key: K,
    value: T[K],
    dummyT: T // <-- dummying
) => {
    const data = cacheGet<T>(store, hash);
    const updatedData = [...data.filter((item) => item[key] !== value)];
    cachePut(store, hash, updatedData);
};

const _ = null!;
cacheDel(cacheStore, "someA", "id", "not-a-number", _ as ItemA); // error
cacheDel(cacheStore, "someA", "id", 1, _ as ItemA); // okay
cacheDel(cacheStore, "someB", "token", 1, _ as ItemB); // error
cacheDel(cacheStore, "someB", "token", "okay", _ as ItemB); // okay

So you have to change how you call cacheDel by putting ,_ as ItemA instead of <ItemA>, but it has the effect you want.


If you really don't want to change the runtime implementation of cacheDel, you could try a combination of the above approaches, by using branding on one of the existing parameters. So instead of <T, K>(k: K) => void, you have <T, K>(k: Branded<K, T>) => void where Branded<K, T> is actually of type K, but you've "branded" it with some type information about T, like type Branded<K> = K & {__brand: T}.

Instead of going into explaining that in the abstract, I'll show the example for cacheDel:

type HashFor<T> = keyof CacheStore & { __dataItemType: T };
const hashFor = <T,>(k: keyof CacheStore) => k as HashFor<T>;

const cacheDel = <T, K extends keyof T>(
    store: CacheStore,
    hash: HashFor<T>,
    key: K,
    value: T[K],
) => {
    const data = cacheGet<T>(store, hash);
    const updatedData = [...data.filter((item) => item[key] !== value)];
    cachePut(store, hash, updatedData);
};

cacheDel(cacheStore, hashFor<ItemA>("someA"), "id", "not-a-number"); // error
cacheDel(cacheStore, hashFor<ItemA>("someA"), "id", 1); // okay
cacheDel(cacheStore, hashFor<ItemB>("someB"), "token", 1); // error
cacheDel(cacheStore, hashFor<ItemB>("someB"), "token", "okay"); // okay

Conceptually, the value you pass in for the hash parameter should determine the type of the data being stored, but the compiler doesn't know about it. The developer can help the compiler by telling it that, say, "someA" points to an array of ItemA elements. We do so by passing in hashFor<ItemA>("someA"). If you look at the implementation of hashFor(), you'll see that it just returns its argument, so at runtime it's just "someA" and the implementation of cacheDel() doesn't have to change. But at compiler is being tricked into believing that the returned value has a __dataItemType property of type ItemA, which means that now the compiler can infer ItemA when you call cacheDel.

As mentioned, it's a combination of the currying and dummying approaches; you need an extra function call where you manually specify the type argument, hashFor<T>() versus cacheDel<T>(), and you need to pretend to pass in a value of the type T in order for the compiler to infer things, pretending "someA" is of type "someA" & {__itemDataType: T} vs pretending null is of type T.


Anyway, hopefully one of the three workarounds presented will suffice for your use case... until and unless partial type argument inference is implemented.

Playground link to code

  • Related