#Need help understanding how to type a function

17 messages · Page 1 of 1 (latest)

fallow stump
#

I am brand new to TS and have figured out a bunch, but I am having issues wrapping my head around how to do the following:

I am creating a Discord Bot template, which I will use for many different bots. A bot must have a Discord API key and app ID, but it could have additional API keys depending on internal functions, like perhaps accessing a weather API or logging API.

So, I want to create a standard object to attach to the bot; this object must have an API key and app ID, plus any additional key: value pairs. This is what I came up with for the object, seemed pretty basic:

/*
 * A SecretsStore contains a discordAPIKey and a discordAPPID in addition
 * to any additional secrets needed based on logger settings, etc.
 */
export type SecretsStore = {
    [key: string]: string;
    discordAPIKey: string;
    discordAPPID: string;
};

Now, this is the part I am having difficulty figuring out. Secrets could be stored in several different management systems, and a bot would use one. For example, the secrets could be stored in the environment, config file, or Azure Key Vault. I want to define something like an abstract function, which enforces that the function must take an optional parameter containing additional API keys and return a SecretsStore object. The functions that "implement" this would do whatever they need to to get the secrets and create the SecretStore (ex: get the env variables or set up and access a vault). So I came up with this (a loader function may need additional options, like a key vailt ur, so the parameters need to account for requiring the one but allowing others.:

/*
 * A SecretsStoreLoader defines a function that creates a SecretsStore
 */
export type SecretsStoreLoader = (options?: {
    [key: string]: string | string[];
    additionalKeys: string[];
}) => SecretsStore;

This is all good, but when I do something like the folowing, I get an error that the definitions do not match:

const loader: SecretsStoreLoader = (options?: {
    azzureURL: 'test';
    additionalKeys?: string[];
}) => {....}

And something like this gets no errors:

const loader: SecretsStoreLoader = () => {...}

So, I know I am not getting this right, and I think it is probably more fundamental and foundational than just the code I have made so far. I may also be overthinking this. But my ultimate goal is to ensure that any secrets loader must account for those possible optional keys. That way, the loader can be used in more than one bot implementation.

TLDR: can I define the shape of a function and specify that any implementing function must take a specific parameter but can have more parameters and return a specified object type?

#

An addition:

I originally did this with classes, and it worked out really well, until it came to systems relying on promises. So for example the Azure one. What I did for the classes is had the constructor load the properties. But from everything I could tell you cannot await promises in a constructor, so I ended up with a class that may or may not have the values at this time, which clearly not gonna work.

rose quiver
#

i think the index signature and optionalness of options are just going to confuse things, so let's use this simpler example to gesture at:

leaden zealotBOT
#
type SecretsStore = {
    [key: string]: string;
    discordAPIKey: string;
    discordAPPID: string;
};

type SecretsStoreLoader = (options: {}) => SecretsStore;

const loader: SecretsStoreLoader = (options: { azzureURL: 'test' }) => {
//    ^^^^^^
// Type '(options: {    azzureURL: 'test';}) => never' is not assignable to type 'SecretsStoreLoader'.
//   Types of parameters 'options' and 'options' are incompatible.
//     Property 'azzureURL' is missing in type '{}' but required in type '{ azzureURL: "test"; }'.
  throw 'not implemented'
};
rose quiver
#

@fallow stump have you used any other statically-typed programming languages before? i'll explain what's going on, but am wondering what terminology i can use that you'll be familiar with

#

anyway the important concept to grok here is variance. function types are contravariant with respect to their parameter types

#

your SecretsStoreLoader type says that any values of that type can safely be called with an empty object:

loader({})
#

but the loader you defined there requires an azzureURL property to be present

#

imagine the implementation does something like this:

const loader: SecretsStoreLoader = (options: { azzureURL: 'test' }) => {
  fetch(options.azzureURL)
  // other stuff...
};
#

then when called as loader({}) (which the type says is okay) you'd get a runtime error in the implementation

rose quiver
fallow stump
fallow stump
# rose quiver i'm curious what your class-based solution looked like. you should be able to re...
export abstract class SecretsStore {
    [key: string]: string | string[] | Promise<any>;
    discordAPIKey!: string;
    discordAPPID!: string;
    keys: string[] = ['discordAPIKey', 'discordAPPID'];

    constructor(options?: { additionalKeys?: string[] }) {
        if (options?.additionalKeys) {
            this.keys = this.keys.concat(options.additionalKeys);
        }
    }
}

export class AzureSecretsStore extends SecretsStore {
    constructor(options: {
        azureVaultURL: string;
        azureAlways?: boolean;
        additionalKeys?: string[];
    }) {
        super(options);

        if (
            options.azureAlways === true ||
            process.env['NODE_ENV'] === 'production'
        ) {
            const credential = new DefaultAzureCredential();
            const client = new SecretClient(options.azureVaultURL, credential);

            const secrets = [];
            for (const key of this.keys) {
                secrets.push(client.getSecret(key));
            }

            Promise.all(secrets)
                .then((secrets) => {
                    for (const secret of secrets) {
                        if (secret.value) {
                            this[secret.name] = secret.value!;
                        } else {
                            throw new Error(
                                `Azure Key Vault secret ${secret.name} missing value.`,
                            );
                        }
                    }
                })
                .catch((error) => {
                    throw new Error(String(error), { cause: error });
                });
        } else {
            for (const key of this.keys) {
                if (process.env[key]) {
                    this[key] = process.env[key]!;
                } else {
                    throw new Error(`Missing ${key} in environment.`);
                }
            }
        }
    }
}

#

It also occured to me that I may be trying to use typings for something that is better handled by testing. Ie, verifying that any loader works with or without the additionalKeys being provided.

rose quiver
#

what level do you want to chat about this on? i could help you come up with other alternative ways to architect this stuff (the approaches you shared seem kinda overly-complicated to me, but then again i don't know all the requirements), but i could also just help you get the class-based version working if that's what you want. totally happy to do either one, just let me know

#

(also FYI i have plans tonight and will be mostly offline, but should be around tomorrow)

fallow stump