Generating TypeScript types for environment variables

Osama Qarem's photo
Osama Qarem
4 min read
Post big cover image

As a React Native developer, I use react-native-config to manage different environments. I create .env, .env.staging, and .env.prod for development, staging and production at the root of my project.

Assuming my .env file looks like:

BASH
BASE_URL=https://localhost:8000

Then I'm able to do:

JS
import BuildConfig from "react-native-config"
console.log(BuildConfig.BASE_URL) https://localhost:8000

Seems good. Works fine. But not for me. There is no autocomplete. It's not typesafe. It's prone to human error that is only noticable it at runtime.

Whenever I go back to native development with Android Studio I'd get jealous of that typesafe autocomplete. How can we get something like that for React Native?

Let's get some understanding of how it works for Android first. Gradle is the build tool used for android's build system. Whenever the android app is built, a class is generated describing environment variables allowing for typesafe environment variable access.

Here is an illustration:

To bring that experience to React Native, we need to make a type declaration file that describes our environment variables module. That will let typescript know how to autocomplete. With a single environment variable, it will look like this:

TS
// .env
declare module "react-native-config" {
interface Env {
BASE_URL: "https://localhost:8000"
}
const BuildConfig: Env
export default BuildConfig
}

Now once we import react-native-config module, we should get autocomplete.

But that's not as good. We don't want to have to update our type declaration file manually!

For that, I resorted to writing quite a lengthy Node.js script. In cough-cough plain javascript:

JS
const fs = require("fs")
const contents = () => {
const env = fs.readFileSync(".env", { encoding: "ASCII" })
const envStaging = fs.readFileSync(".env.staging", { encoding: "ASCII" })
const envProd = fs.readFileSync(".env.prod", { encoding: "ASCII" })
const envLines = env.split("\n")
const envStagingLines = envStaging.split("\n")
const envProdLines = envProd.split("\n")
let filteredEnv = []
let filteredEnvStaging = []
let filteredEnvProd = []
// Assumption: all files have the same number of lines
for (let index = 0; index < envLines.length; index++) {
const envLine = envLines[index]
const envStagingLine = envStagingLines[index]
const envProdLine = envProdLines[index]
if (envLine.includes("=")) {
if (envLine.includes("#")) {
filteredEnv.push(envLine.split("#")[1].trim())
} else {
filteredEnv.push(envLine.trim())
}
}
if (envStagingLine.includes("=")) {
if (envStagingLine.includes("#")) {
filteredEnvStaging.push(envStagingLine.split("#")[1].trim())
} else {
filteredEnvStaging.push(envStagingLine.trim())
}
}
if (envProdLine.includes("=")) {
if (envProdLine.includes("#")) {
filteredEnvProd.push(envProdLine.split("#")[1].trim())
} else {
filteredEnvProd.push(envProdLine.trim())
}
}
}
return [filteredEnv, filteredEnvProd, filteredEnvStaging]
}
const generate = () => {
const [filteredEnv, filteredEnvProd, filteredEnvStaging] = contents()
let envVariableNamesArray = []
let envVariableValuesArray = []
for (let i = 0; i < filteredEnv.length; i++) {
// Assumption: the files we read are not just comments
const envPair = filteredEnv[i].split("=")
const envStagingValue = filteredEnvStaging[i].split("=")[1]
const envProdValue = filteredEnvProd[i].split("=")[1]
envVariableNamesArray.push(envPair[0])
envVariableValuesArray.push(envPair[1], envStagingValue, envProdValue)
}
// Assumption: for every name/key there are 3 values (env, env.staging, env.prod)
let table = []
let valuesCursor = 0
for (let i = 0; i < envVariableNamesArray.length; i++) {
table[i] = [envVariableNamesArray[i], []]
const totalPushCount = 3
let current = 0
while (current !== totalPushCount) {
const valueToPush = envVariableValuesArray[valuesCursor]
if (!table[i][1].includes(valueToPush)) {
table[i][1].push(valueToPush)
}
valuesCursor++
current++
}
}
const stringArrayMap = table.map((nameValueArray) => {
const name = nameValueArray[0]
const valuesArray = nameValueArray[1]
let string = `${name}: `
valuesArray.forEach((value, index) => {
if (index === 0) {
string = string.concat(`"${value}"`)
} else {
string = string.concat(` | "${value}"`)
}
})
return string
})
const string = `declare module "react-native-config" {
interface Env {
${stringArrayMap.join("\n ")}
}
const Config: Env
export default Config
}`
fs.writeFileSync("env.d.ts", string, "utf8")
}
generate()

In summary, this script will read all 3 environment files and generate a .env.d.ts describing the types. It will only work if all 3 .env files contain the same number of variables with the same names, which makes sense.

At the root directory of my react native project, I created a scripts folder and placed it there. It looks like this MyApp/scripts/generateEnvTypes.js. Next I added the following npm script to my package.json:

"generate-env-types": "node scripts/generateEnvTypes.js"

Now, whenever I update my environment variables, I simply run the npm script and a new type declarations file is automatically generated! 🎉


PS: I'm maintaining a React Native template with a lot of goodies like the one in the article.

Enjoying this content?


Feel free to join the newsletter and I'll let you know about new posts 💙