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?
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]>
}
Live demo (to check runtime behaviour): https://playcode.io/1751585