Type safe environment variables
If you've been working with Typescript and needed to access environment variables on your code, you must've noticed that
after you type process.env
you don't get any suggestions. And that makes a lot of sense, because there's no way
Typescript can effectively know what are the variables living in your env
file. That happens due to the fact that it's
code you never import, and is executed at runtime.
Personally, when working with Typescript I like to get as much help from it as possible. Now, I know what you're thinking. We usually don't go around using env variables a hundred times across our codebases, it's only on selected spots of our codebase where we do use them. But still, arguably those places where you use them are really imporant. Better safe than sorry!
So, how can we leverage the help from Typescript here?
Zod
We use zod! Zod will not only help us get typed environment variables, but also will parse them and check if we are missing any.
So, first off, on your src
(or similar) folder, create a env.ts
file with the following:
// env.tsimport {z} from 'zod'
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), SOME_SUPER_SECRET_TOKEN: z.string().nonemtpy(),})
export const ENV = envSchema.parse(process.env)
There are a couple of things to notice in this piece of snippet:
- We are exporting an
ENV
constant, which parses our entireprocess.env
object containing our environment variables against our defined schema. That means that, ifprocess.env
is missing any of the variables declared on yourenvSchema
schema,zod
will throw an error as soon as this file gets imported - Notice on line 6, how we chain a
.nonempty()
method to the string parse? That's so you don't accidentally go and create a env variable like so:SOME_SUPER_SECRET_TOKEN=
(which is valid on your.env
file). If you do, zod will warn you about it - Because of how
zod
works, any other variable that's not defined in the schema will get filtered out. So this helps declaring in one single place all the variables that you'll be using on your entire application. This is extremely useful, as this file tends to get really big over time, specially on big applications
With this, now we can import our newly created ENV
constant and safely use our env variables 🎉
// Somewhere on your appimport {ENV} from '~/env'
const connection = connect({ token: ENV.SOME_SUPER_SECRET_TOKEN,})
Parse as soon as possible
There's a problem with this approach. What if the first time we use our ENV
object is with an invalid/unset variable
deep inside our component tree? Our app will run like nothing happened, until it reaches that point and blows up
eventually.
To avoid this, ideally we'd want to parse our process.env
as soon as possible. So let's do that!
In order to achive this, we need to parse
our schema as soon as our app is mounted. For that, we need to tweak our
env.ts
file a bit:
// env.tsimport {z} from 'zod'
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), SOME_SUPER_SECRET_TOKEN: z.string().nonemtpy(),})
try { envSchema.parse(process.env)} catch (error) { if (error instanceof z.ZodError) { const {fieldErrors} = error.flatten() const errorMessage = Object.entries(fieldErrors) .map(([field, errors]) => errors ? `${field}: ${errors.join(', ')}` : field ) .join('\n ') throw new Error(`Missing environment variables:\n ${errorMessage}`) }}
export const ENV = envSchema.parse(process.env)
This try catch
block will run as soon as this file gets imported, and throw a more elegant and readable error message
listing the environment variables that were problematic.
So now all we need to do is import this file at the root of our application. Which file this is depends on the framework you're using:
Remix
// entry.server.tsimport '~/env.server' // Notice the .server on the file name
Nextjs
// app/layout.tsximport '~/env'
Now our env.ts
will be executed as soon as our app is run.
Declaration merging
There's one more thing we can add to our file before we close it at take it home.
Depending on the configuration of your project, there are places where you'd still need to access process.env
directly. Doing so with our current solution will still throw you into the unknown.
# .envFOO_ACCESS_TOKEN=1234
// some.config.tsconst accessToken = process.env.eerk // This will not lint, nor will it suggest variables based on your .env file
To avoid this we can use declaration merging. We interfere the global ProcessEnv
interface under the NodeJS
namespace, and add to it our inferred typed variables. This is how:
// env.tsimport {z} from 'zod'
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), SOME_SUPER_SECRET_TOKEN: z.string().nonemtpy(),})
declare global { namespace NodeJS { interface ProcessEnv extends z.infer<typeof envSchema> {} }}
try { envSchema.parse(process.env)} catch (error) { if (error instanceof z.ZodError) { const {fieldErrors} = error.flatten() const errorMessage = Object.entries(fieldErrors) .map(([field, errors]) => errors ? `${field}: ${errors.join(', ')}` : field ) .join('\n ') throw new Error(`Missing environment variables:\n ${errorMessage}`) }}
export const ENV = envSchema.parse(process.env)
And that's it! Now you have the best from both worlds: a fully typed process.env
object and a new ENV
constant that
you can safely use at your leisure anywhere on your app.
Feel free to reach out to me on Twitter @gonstoll, Linkedin or by mail if you have any questions, comments or suggestions :)
Til the next one!