1

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);
Felipe Buccioni
  • 19,109
  • 2
  • 28
  • 28
  • 1
    Please consider providing a self-contained [mre] that we can copy and paste into our own IDEs to see what you're talking about without unrelated errors. TypeScript by itself doesn't have `process` (that's node specific, right?) or `dbFromURL` or `Knex` in scope. It would be helpful if you'd either define/import them (and if importing, tag the question with appropriate dependencies), or even better: replace them with native things. Anything that makes it easier for others to get immediately to work on the issue will make it more likely you get a useful answer. – jcalz May 23 '23 at 17:11
  • @jcalz I understand what you saying, but the question is not about run, the app works wth my workaround, indeed you can use other types instead the knex connection. – Felipe Buccioni May 23 '23 at 18:44
  • I am not talking about runtime either; I'm talking about being able to copy and paste that code into a standalone TypeScript IDE and reproduce the typing issue you're talking about. If you [edit] the code here to be a [mre] then I'll take another look; otherwise we are at an impasse and I'll disengage to cut down on noise so as not to distract others. Good luck! – jcalz May 23 '23 at 18:48
  • @jcalz Now are minimal reproducible example, hope now you can rest comftably tonight XD – Felipe Buccioni May 23 '23 at 21:17
  • Does [this approach](https://tsplay.dev/NDdJ4N) meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz May 23 '23 at 21:35
  • @,jcalz sure, does the job – Felipe Buccioni May 24 '23 at 03:07

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360