Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: generic function to provide typesafe access to variable not exported from module

I have a module with an object containing a cache of items of different types, each stored in a property of the cache object:

type Pet = { name: string, petFood: string }
type Human = { firstName: string, lastName: string }
type Cache = { pets: Pet[], humans: Human[] }
const theCache: Cache = {pets: [], humans: []}

I have a function to retrieve data from the cache. It takes in a key, representing a property of the cache (i.e. pets or humans), and a generic type specifying the type of data I expect that property to return (i.e. Pet or Human). I can use conditional types to achieve type safety here:

// Alias for all properties of TObj which return a TResult
export type PropertiesOfType<TObj, TResult> = {
    [K in keyof TObj]: TObj[K] extends TResult ? K : never
}[keyof TObj]

function readCache<T, K extends PropertiesOfType<Cache, T[]>, C extends { [key in K]: T[] }>(
    key: K, cache: C
): T[] {
    return cache[key]
}

const pets: Pet[] = readCache("pets", theCache) // Compiles - this is correct
const wrongType: Human[] = readCache("pets", theCache) // Doesn't compile - this is correct

Within the module that all works fine, as I can pass theCache into the readCache function. However, theCache is not exported as I want to keep it private to the module. Instead, I want to export a function that other modules can call to get read-only access to data in the cache, e.g. something like this:

export function getCachedItems<T, K extends PropertiesOfType<Cache, T[]>>(key: K): T[] {
    return readCache(key, theCache) // Doesn't compile
}

The problem is that the code above doesn't compile:

TS2345: Argument of type 'Cache' is not assignable to
parameter of type '{ [key in K]: T[]; }'

Is there a way I can get the code to compile (without having to do an unsafe cast)?

UPDATE: Titian Cernicova-Dragomir made two great suggestions:

  1. Return Cache[K]. This works fine for places where I want to call the method and know the type I'm working with. But in cases where I have another generic function which wants to call the generic getCachedItems, and know that what it gets back is an array of T, that won't work.
  2. Return Cache[K][number][]. This solves the above problem (and answers the original question). But this only works when the cache contains arrays. What if I want the cache object to be slightly different (more like a Dictionary or Map in other languages): each entry in the cache should itself be another object, with a property for each item which it caches, where the name of the property is some ID of that object, as suggested when designing a Redux store.

In other words I'd have this:

export type Map<T> = { [key: string]: T }
export type Cache = { pets: Map<Pet>, humans: Map<Human> }
const theCache: Cache = {pets: {}, humans: {}}

function readCache<T, K extends PropertiesOfType<Cache, Map<T>>, C extends { [key in K]: Map<T> }>(
    key: K, cache: C
): Map<T> {
    return cache[key]
}

const pets: Map<Pet> = readCache("pets", theCache) // Compiles - this is correct
const wrongType: Map<Human> = readCache("pets", theCache) // Doesn't compile - this is correct

export function getCachedItems<T, K extends PropertiesOfType<Cache, Map<T>>>(key: K): Map<T> {
    return readCache(key, theCache) // Doesn't compile
}

So I still get the original problem that way. If I try the suggestion of returning C[K], again, it works if I know the type when calling it, but not in a generic function:

function readCache<T, K extends keyof C, C extends { [key in K]: Map<T> }>(
    key: K, cache: C
): C[K] {
    return cache[key]
}

const pets: Map<Pet> = readCache("pets", theCache) // Compiles - this is correct
const wrongType: Map<Human> = readCache("pets", theCache) // Doesn't compile - this is correct

export function getCachedItems<T, K extends keyof Cache>(key: K): Cache[K] {
    return readCache(key, theCache)
}

const pets2: Map<Pet> = getCachedItems("pets") // Compiles - this is correct
const wrongType2: Map<Human> = getCachedItems("pets") // Doesn't compile - this is correct

function callerInAnotherModule<T, K extends keyof Cache>(key: K) {
    const expected : Map<T> = getCachedItems(key) // Doesn't compile
}
like image 496
Yoni Gibbs Avatar asked Apr 09 '26 12:04

Yoni Gibbs


1 Answers

Conditional types that still contain unresolved type parameters are usually a a problem as the compiler can't expand them.

If cache only contains relevant keys an approach that works better is to use index type queries, they produce the same results in this case but are more friendly to the compiler.

type Pet = { name: string, petFood: string }
type Human = { firstName: string, lastName: string }
type Cache = { pets: Pet[], humans: Human[] }
const theCache: Cache = {pets: [], humans: []}

function readCache<K extends keyof C, C extends { [key in K]: any[] }>(
    key: K, cache: C
): C[K] {
    return cache[key]
}

const pets: Pet[] = readCache("pets", theCache) // Compiles - this is correct
const wrongType: Human[] = readCache("pets", theCache)

export function getCachedItems<K extends keyof Cache>(key: K): Cache[K]  {
    return readCache(key, theCache) // ok
}

While the answer above works, the request in the comments is to be able to treat the result of readCache as an array. While Cache[K] is a union of arrays, its methods are mangled and hard to use. We can drill down one more level and get the item type from Cache[K] using Cache[K][number] and use this as the array item in the result. This will make arrays work well in methods like getCachedItems:

type Pet = { id: string; name: string, petFood: string }
type Human = { id: string; firstName: string, lastName: string }
type Cache = { pets: Pet[], humans: Human[] }
const theCache: Cache = {pets: [], humans: []}

function readCache<K extends keyof C, C extends { [key in K]: any[] }>(
    key: K, cache: C
): C[K][number][] {
    return cache[key]
}

const pets: Pet[] = readCache("pets", theCache) // Compiles - this is correct

export function getCachedItems<K extends keyof Cache>(key: K): Cache[K][number][]  {
    return readCache(key, theCache) // ok
}

export function getCachedItemsAndMap<K extends keyof Cache>(key: K)  {
    return readCache(key, theCache)
        .map(o => ({  // map works fine
            id: o.id, // we can even access common members
            item: o
        }));
}

Edit

Version with the Map type added to the question:

type Pet = { name: string, petFood: string }
type Human = { firstName: string, lastName: string }

export type Map<T> = { [key: string]: T }
export type Cache = { pets: Map<Pet>, humans: Map<Human> }
const theCache: Cache = {pets: {}, humans: {}}


function readCache<K extends keyof C, C extends { [key in K]: Map<any> }>(
    key: K, cache: C
): Map<C[K][string]> {
    return cache[key]
}

const pets: Map<Pet> = readCache("pets", theCache) // Compiles - this is correct

export function getCachedItems<K extends keyof Cache>(key: K): Map<Cache[K][string]>  {
    return readCache(key, theCache) // ok
}

export function getCachedItemsAndMap<K extends keyof Cache>(key: K)  {
    let cache: Map<Cache[K][string]> = readCache(key, theCache)
}

Edit v2

Actually I don't think the whole is needed since there are no relevant methods on the Map you want to access. This also works:

type Pet = { name: string, petFood: string }
type Human = { firstName: string, lastName: string }

export type Map<T> = { [key: string]: T }
export type Cache = { pets: Map<Pet>, humans: Map<Human> }
const theCache: Cache = {pets: {}, humans: {}}


function readCache<K extends keyof C, C extends { [key in K]: Map<any> }>(
    key: K, cache: C
): C[K] {
    return cache[key]
}

const pets: Map<Pet> = readCache("pets", theCache) // Compiles - this is correct

export function getCachedItems<K extends keyof Cache>(key: K): Cache[K]  {
    return readCache(key, theCache) // ok
}

export function getCachedItemsAndMap<K extends keyof Cache>(key: K)  {
    let cache: Cache[K] = readCache(key, theCache)
    let a = cache[''] // Pet | Human
}
like image 56
Titian Cernicova-Dragomir Avatar answered Apr 11 '26 00:04

Titian Cernicova-Dragomir



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!