Typing environment variables the right way with TS

typescript4 min

byLucas Santos

This page was machine translated. Read original / Suggest a fix

An extremely common problem we face with TypeScript is typing external files, and the main version of that problem is when we have to type things that come from the system where we’re running the application, for example, process.env.

I’ve seen several techniques for typing and even converting these values, but the vast majority of them have fundamental flaws. I’ll show a few here and which ones I prefer to use.

The problem#

When we’re dealing with environment variables, we’re dealing with one of the main sources of unknown values possible in TypeScript. First, we’re dealing with an external value that may or may not exist, so it won’t have autocomplete or intellisense when you type:

process.env.
// ^ We don't have autocomplete here

Then, even if we do have a valid variable, for example, a server’s port:

process.env.PORT

TypeScript has no way of knowing how to type the variable because it could be undefined, so, correctly, it types everything that comes from process.env as string | undefined, which turns using these variables into a nightmare when we have to pass them to functions:

function foo (x: string) {
return x.toLowerCase()
}
foo(process.env.UMA_STRING) // error

The solutions#

Let’s go through some possible solutions. I’ll leave a few options here and comment on them at the end of each section.

Type Augmentation#

Type extension is a more advanced TypeScript technique that’s quite useful when you’re dealing with modules that have no typing at all and/or are external to your system. In other words, you can essentially tell TypeScript what types you want for a module you already have installed but don’t own.

For example, we can ask TypeScript to override Node.js’s global object by adding the correct typing for our envs. We can do it like this:

envs.d.ts
namespace NodeJS {
interface ProcessEnv {
PORT: string
}
}

What you’re essentially doing is using a concept called declaration merging to merge the two objects together and override TS’s natural typing with yours.

This approach is quite useful for the following cases:

  • Small applications
  • Few environment variables
  • No need for them to have a type other than string

But it has critical problems:

  • If the variable can only be of a specific type, you’re not converting it
  • It doesn’t guarantee the variable exists on the system
  • Checking happens only at compile time

Besides that, using declaration files to override global objects isn’t necessarily considered a good practice.

Conversion object#

Another, more “manual” way to do this is to create a simple object and assign the values to it, casting these values manually:

const envs = {
PORT: process.env.PORT as string
}

This approach has even more problems than the previous one, because:

  • You’re forcing the type conversion, meaning that, as far as your code is concerned, the env always exists
  • You have no way to throw an error when required variables are missing without writing more code
  • It doesn’t guarantee anything, neither at runtime nor at compile time

Use a validation lib (Zod)#

This is my preferred method. While researching for this article I found another article that talks about a tool called t3-env. Essentially you could use it something like this (example from the other article):

import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
OPEN_AI_API_KEY: z.string().min(1),
},
clientPrefix: "PUBLIC_",
client: {
PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
},
runtimeEnv: process.env,
});

While it’s quite an interesting way to create validation that works both at runtime and at compile time, I don’t see the point in having that package, since you can just use Zod on its own to do all of this in a much simpler and much cleaner way.

T3’s case is a variation because it also handles typing for client variables, which I personally don’t like that much. I prefer to keep the two things separate.

With just Zod installed as a package, you can create a file called config.ts, where you can have not only all your environment variables but also any other configuration you might want to pass to your app:

import { z } from 'zod'
const appConfigSchema = z.object({
PORT: z.coerce.number().min(1024).max(65535).default(3000),
DATABASE_HOST: z.string(),
DATABASE_USER: z.string(),
MAIN_EMAIL: z.string().email(),
MAIN_ACCOUNT_ID: z.string().uuid().optional()
})
export type AppConfig = z.infer<typeof appConfig>
export const appConfig = appConfigSchema.parse(process.env)

And then you just use it in your application, from anywhere:

import { appConfig } from '../config.ts'
console.log(appConfig.PORT) // number between 1024 and 65535, default 3000

This check doesn’t just guarantee the variables exist on your system, because otherwise Zod will throw a null value error, it also guarantees that the types at runtime will be valid types within your schema.

Another, slightly more “ready-made” option is to use znv, which does exactly the same thing, except it has a somewhat nicer-looking error listing.