Search code examples
typescripttypescript-typingstypescript-genericses6-proxy

Implement a dynamic lazy proxy collection class on typescript


I'm trying to implement a lazy database connection collection in typescript. I'm creating a class called DatabaseCollection and my idea is to use a proxy to lazy load the connections (I'm using knex as connector) the class works fine in terms of node context, but Typescript says "property does not exists" and I don't know how to tell to Typescript about a dynamic properties class.

I use a workaround converting to Record<string, Knex> but I want to implement it properly, thanks in advance.

Here is the code:

const debug = true
process.env['DB_TEST_URL'] = 'mysql://root@localhost/test';

class NotImportantConnector {
    public url: string;

    constructor(url: string) {
        this.url = url;
    }
}

function dbFromURL(url: string): NotImportantConnector {
    return new NotImportantConnector(url);
}

class DatabasesCollection<T> {
    protected databases: Record<string, T> = {};

    constructor() {
        return new Proxy(this, this) as any;
    }

    get (target: DatabasesCollection<T>, name: string): T {
        if (name in target.databases) {
            return target.databases[name];
        }

        const envName = `DB_${name.replace(/[A-Z]/g, l => `_${l}`).toUpperCase()}_URL`;

        if (!process.env[envName]) {
            throw new Error(`Call to database ${name} needs ${envName} environment variable to run`);
        }

        target.databases[name] = dbFromURL(process.env[envName] as string) as T;
        return target.databases[name];
    }
}


// Not working with error Property 'test' does not exist on type 'DatabasesCollection<NotImportantConnector>'.
// const db = new DatabasesCollection<NotImportantConnector>();

// Workaround, using as 
const db = new DatabasesCollection<NotImportantConnector>() as unknown as Record<string, NotImportantConnector>;

console.log(db.test);

Solution

  • TypeScript isn't going to make it easy to use a class declaration to make DatabasesCollection<T> behave the way you want, which is that every instance should presumably have all the known properties of the class, plus an index signature where every other property key has a value of type T. But TypeScript can't directly represent this "every other property" concept; there's a longstanding open feature request for this at microsoft/TypeScript#17867, but it's not part of the language yet. So adding an index signature to the class directly won't behave exactly as desired. See How to define Typescript type as a dictionary of strings but with one numeric "id" property for various alternative approaches in general.

    For your use case, it would be acceptable to make instances of DatabasesCollection<T> be the intersection of the known instance type with {[k: string]: T} (aka Record<string, T>). This behaves well enough when accessing a value of that type... although it's hard to actually produce a value of that type in a way the compiler sees as type safe.

    And that means we'll need to use something like a type assertion to convince the compiler that your class constructor produces instances of that type.

    To do this, I'd suggest renaming your DatabasesCollection<T> class out of the way to, say, _DatabasesCollection<T> so we can use the name DatabasesCollection for the name of the desired instance type (_DatabasesCollection<T> & Record<string, T>), and the name of the asserted class constructor. Like this:

    class _DatabasesCollection<T> {
      ⋯ // same impl, more or less
    }
    
    type DatabasesCollection<T> = _DatabasesCollection<T> & Record<string, T>;
    const DatabasesCollection = _DatabasesCollection as new <T>() => DatabasesCollection<T>;
    

    Let's test it out:

    const db = new DatabasesCollection<NotImportantConnector>();
    // const db: DatabasesCollection<NotImportantConnector>
    
    const t = db.test;
    // const t: NotImportantConnector
    

    Looks good.

    Playground link to code