Search code examples
reactjstypescriptcreate-react-app

Can I use TypeScript to Populate a Strongly-Typed 'Environment' Object from a Dictionary/Record of Strings?


I'm not sure if this is possible (or practical) but I'm curious.


I have a strongly-typed dictionary of string variables like so:

const variables: Variables = {
    REACT_APP_MY_VARIABLE: 'Test',
    REACT_APP_MY_BOOLEAN_VARIABLE: 'true'
};

I'm accessing these variables directly through the variables object: const myVariable = variables.REACT_APP_MY_VARIABLE; etc.

The "problem" is that for some of these values a string is not a sensible representation of the value (e.g. 'true'), so I have to convert it in some manner before using it.

const myBoolean = toBoolean(variables.REACT_APP_MY_BOOLEAN_VARIABLE);

This is mostly fine... but it'd be really nice if it were possible to have the converted values stored in an object, alongside all the other values which are stored as strings by default if a conversion isn't specified.

I'd thought of making a kind of wrapper class to store the raw strings in, and then having some getters that do the conversion, but I'd like to be able to automate this rather than exhaustively creating getters for every value.

class EnvironmentVariables {
    // This should actually be a private variable but my keyboard 
    // doesn't have a hash key.
    private _variables: Variables;
    constructor(variables: Variables) {
        this._variables = variables;
    }

    get myBooleanVariable() {
        return toBoolean(this._variables.REACT_APP_MY_BOOLEAN_VARIABLE);
    }
}

Now I can get my boolean value nicely:

const environmentVariables = new EnvironmentVariables(variables);

// myBoolean = true
const myBoolean = environmentVariables.myBooleanVariable;

If I want the others though, I have to make a getter for each and that's quite a lot of effort when the most common case is just returning the string value:

// I don't want to make one of these for every value.
get myVariable() {
    return this._variables.REACT_APP_MY_VARIABLE;
}

To do this, I can add 'default' getters programmatically for any values without an explicit override, and have it just map to the internal _variables collection.

constructor(variables: Variables) {
    this._variables = variables;

    for (const key in variables) {
        const getterName = key.replace('REACT_APP_', '').toCamelCase();

        if (this.hasOwnProperty(getterName)) {
            continue;
        }

        Object.defineProperty(this, getterName, {
            get: () => this._variables[key];
        });
    }
}

This works great! Except I can't figure out how to get TypeScript to recognise that when I add a value to variables, that value should be accessible also through EnvironmentVariables.

const environmentVariables = new EnvironmentVariables(variables);

// myBooleanVariable = true, I am happy.
const myBooleanVariable = environmentVariables.myBooleanVariable;

// myVariable = 'Test'... but TypeScript gives an error saying there's
// no 'myVariable' on type 'EnvironmentVariables'.
const myVariable = environmentVariables.myVariable;

I'd also ideally really like it to be able to mutate the variable name into camel-case, and remove the 'REACT_APP' prefix as it's quite clunky. I'm sceptical whether this is possible, but I thought maybe it's something that can be done with template types and generics?

Essentially, I want something that works like this:

const variables = {
    REACT_APP_MY_VARIABLE: 'Test',
    REACT_APP_MY_BOOLEAN_VARIABLE: 'true'
};

class EnvironmentVariables {
    ... // Magic goes here.

    get myBooleanVariable() {
        return toBoolean(this._variables.REACT_APP_MY_BOOLEAN_VARIABLE);
    }
}

const environmentVariables = new EnvironmentVariables(variables);

// TypeScript allows this even though there's no getter defined in
// EnvironmentVariables, and it recognises that the value is a `string`.
const myVariable = environmentVariables.myVariable;

// There's a getter defined for myBooleanVariable, so TypeScript
// recognises that it is a boolean.
const myBooleanVariable = environmentVariables.myBooleanVariable;

So... is this at all possible, or am I just wasting my time?


Solution

  • making a kind of wrapper class to store the raw strings in, and then having some getters that do the conversion, but I'd like to be able to automate this rather than exhaustively creating getters for every value.

    IIUC, you have a Variables TypeScript type that matches an environment configuration, where all values are actually strings (typically what we get from dotenv), and all keys are in UPPER_SNAKE_CASE and prefixed with "REACT_APP_".

    Now you want some utility to convert some values to their proper expected type (typically "true" string into true boolean), and if possible to convert the keys to camelCase (and to remove the prefix), while minimizing the need for configuration of such utility.

    In that case, as you already figured out, unfortunately there is no cutting on a minimum of configuration, since TypeScript types do not exist at runtime, but you do need the conversion to happen at runtime (when you receive the environment variables).

    In a similar way as the runtime validation use case (see e.g. Joi, Yup, Zod, etc.), you could reverse the approach: write the configuration first as the "source of truth" of the structure of your environment variables, then infer the type of the latter from that configuration.

    able to mutate the variable name into camel-case, and remove the 'REACT_APP' prefix [...] maybe it's something that can be done with template types and generics?

    Indeed TypeScript is now able to perform some limited string manipulation on types. The built-in utilities do not handle snake_case to camelCase conversion (nor the reverse), but there are several solutions available, e.g. in Is it possible to use mapped types in Typescript to change a types key names?

    // Atomic type converters used for configuration
    function envString(envValue: string) {
        return envValue;
    }
    function envBoolean(envValue: "true" | "false") {
        return envValue === "true";
    }
    
    // Example configuration
    const config = {
        // Use camelCase
        myVariable: envString,
        myBooleanVariable: envBoolean,
    }
    
    // Example generating the converterFn
    const myEnvConverter = envConverterFnBuilder(config);
    
    const result = myEnvConverter({
        REACT_APP_MY_VARIABLE: 'Test',
        REACT_APP_MY_BOOLEAN_VARIABLE: 'true'
    }); // Okay
    
    result.myBooleanVariable;
    //     ^? (property) myBooleanVariable: boolean
    

    The above envConverterFnBuilder ConverterFn builder, which uses some helper types, including the mentioned CamelToSnake solution:

    // ConverterFn builder
    function envConverterFnBuilder<T extends { [Key in keyof T]: typeof envString | typeof envBoolean }>(envStructure: T) {
        return function envConverterFn(envDict: ConverterInput<T>): ConverterOutput<T> {
            const result = {} as any; // Will be populated below
            for (const configKey in envStructure) {
                // Convert camelCase key into UPPER_SNAKE_CASE with prefix, e.g. using Lodash
                const ENV_KEY = _.toUpper(_.snakeCase("reactApp_" + configKey)) as keyof ConverterInput<T>;
    
                const atomicConverter = envStructure[configKey];
                result[configKey] = atomicConverter(envDict[ENV_KEY] as any); // We know that the envValue matches the converter because they derive from the same key
            }
            return result;
        };
    }
    
    // Mapped types with key remapping using template literal type manipulation
    type ConverterInput<T extends { [Key in keyof T]: typeof envString | typeof envBoolean }> = {
        [Key in keyof T as Uppercase<CamelToSnake<`reactApp_${string & Key}`>>]: Parameters<T[Key]>[0]
    };
    type ConverterOutput<T extends { [Key in keyof T]: typeof envString | typeof envBoolean }> = {
        [Key in keyof T]: ReturnType<T[Key]>
    }
    

    Playground Link

    Live demo (to check runtime behaviour): https://playcode.io/1751585