Type-Safe Env Vars With Vite - A Modern Approach

Gustavo Guichard (Guga) - Jun 14 - - Dev Community

This post is a revamp of my previous one where I showed you how to use Zod and TS to create a type-safe environment variable system that works on both the client and server.

If you haven't read it yet, go check it out. It provides context for this post.

It's been over 18 months, and things move fast in JS land. I decided to revisit the code, update Remix itself, leverage Vite's Environment Variables, and use my new friend - ArkType - to parse the environment variables this time.

If you only want to see the code, check out the updates' diff here.

The Gist of Changes

By updating Remix from 1.7 to 2.9 and using Vite we can now use import.meta.env so we don't need to manually load the environment variables and expose them to the window anymore. That update reduces a lot of shenanigans from the previous approach.

I'm also switching from Zod to ArkType, introducing a makeTypedEnvironment helper to streamline handling environment variables in both server and client environments. Additionally, there are a few optimizations I'll be pointing out through the post.

The New makeTypedEnvironment Helper

This helper is designed to work seamlessly in both server and client environments, making it a versatile tool. It can handle different parsers, avoid mutating the original objects, and transform environment variable keys to camelCase.

Let's first start by making it work in multiple environments.

// lib/index.ts
// This function creates a typed environment by accepting a Zod schema parser.
function makeTypedEnvironment<T>(schema: { parse: (v: unknown) => T }) {
  // The returned function applies the schema parser to the provided environment variables.
  return (args: Record<string, unknown>): T => schema(args)
}
Enter fullscreen mode Exit fullscreen mode

We can use it in both server or client:

import { z } from 'zod'
import { makeTypedEnvironment } from '~/lib'

// Define the environment Zod schema.
const envSchema = z.object({
  MODE: z.enum(['development', 'test', 'production']).default('development'),
})
// Create the environment parser using the makeTypedEnvironment helper.
const getEnv = makeTypedEnvironment(envSchema)

// Server usage: parse environment variables from process.env
const env = getEnv(process.env)
//    ^? { MODE: 'development' }

// Vite client-side env vars usage: parse environment variables from import.meta.env
const env = getEnv(import.meta.env)
//    ^? { MODE: 'development' }
Enter fullscreen mode Exit fullscreen mode

You could also use getEnv(window.ENV) if you are not using Vite, just follow the instructions on the previous post.

Accepting Different Parsers and Preventing Mutations

To use it with ArkType, I'll make it accept a more generic parser as an argument.

// Function to create a typed environment that accepts a generic parser.
function makeTypedEnvironment<T>(schema: (v: unknown) => T) {
  // Spread the arguments to clone them, avoiding mutations to the original object.
  return (args: Record<string, unknown>): T => schema({ ...args })
}
Enter fullscreen mode Exit fullscreen mode

The args was cloned above to avoid mutations. Some parsers, like ArkType, mutate the object (🥲) passed to them. This way, we ensure the original object is not changed.

Now we can use that function with both Zod and ArkType.

import { z } from 'zod'
import { type } from 'arktype'
import { makeTypedEnvironment } from '~/lib'

// Define the environment schema using Zod.
const envZodSchema = z.object({
  MODE: z.enum(['development', 'test', 'production']).default('development'),
})
// Create the environment parser for Zod.
const getZodEnv = makeTypedEnvironment(envZodSchema.parse)

// Define the environment schema using ArkType.
const envArkSchema = type({
  MODE: ['"development"|"test"|"production"', '=', 'development'],
})
// Create the environment parser for ArkType.
const getArkEnv = makeTypedEnvironment((d) => envArkSchema.assert(d))
Enter fullscreen mode Exit fullscreen mode

Perfect!

Transforming the Env Vars to camelCase

For convenience, I'll use string-ts to transform the env vars to camelCase, making the usage feel more like JS code..

import { camelKeys } from 'string-ts'

// Function to create a typed environment with camelCase transformation.
function makeTypedEnvironment<T>(schema: (v: unknown) => T) {
  // Apply camelCase transformation to the parsed environment variables.
  return (args: Record<string, unknown>) => camelKeys(schema({ ...args }))
}
Enter fullscreen mode Exit fullscreen mode

Now let's use the original public schema from the previous post and see how it looks like:

// environment.server.ts
import { type } from 'arktype'
import { makeTypedEnvironment } from '~/lib'

// Define the environment schema using ArkType.
const publicEnvSchema = type({
  // We prefix the keys with VITE_ to expose them in the client bundle.
  VITE_GOOGLE_MAPS_API_KEY: 'string',
  VITE_STRIPE_PUBLIC_KEY: 'string',
})
// Create the environment parser with camelCase transformation.
const getEnv = makeTypedEnvironment((d) => envSchema.assert(d))
// Parse environment variables from process.env
const env = getEnv(process.env)
//    ^? { viteGoogleMapsApiKey: string, viteStripePublicKey: string }
Enter fullscreen mode Exit fullscreen mode

By leveraging string-ts, I’ve transformed the environment variable keys to camelCase, making them more intuitive to use in JavaScript code. This transformation is applied at both type and runtime levels.

Getting rid of the VITE_ prefix

You may have noticed we are adding the VITE_ prefix to those variables that we want to be exposed to the client bundle through Vite's import.meta.env object. That comes from the Vite API and you can change the prefix if you want.

We can go further and remove that prefix as follows:

import { camelKeys, replaceKeys } from 'string-ts'

function makeTypedEnvironment<T>(schema: (v: unknown) => T) {
  // Apply replaceKeys before camelCase transformation to the parsed environment variables.
  return (args: Record<string, unknown>) =>
    camelKeys(replaceKeys(schema({ ...args }), 'VITE_', ''))
}
Enter fullscreen mode Exit fullscreen mode

Check out the result:

// environment.server.ts
import { type } from 'arktype'
import { makeTypedEnvironment } from '~/lib'

// Define the environment schema using ArkType.
const publicEnvSchema = type({
  VITE_GOOGLE_MAPS_API_KEY: 'string',
  VITE_STRIPE_PUBLIC_KEY: 'string',
})
// Create the environment parser with camelCase transformation.
const getEnv = makeTypedEnvironment((d) => envSchema.assert(d))
// Parse environment variables from process.env
const env = getEnv(process.env)
//    ^? { googleMapsApiKey: string, stripePublicKey: string }
Enter fullscreen mode Exit fullscreen mode

Much better!

Last Optimization: Caching

To enhance performance, I’ve implemented caching in the makeTypedEnvironment function. This prevents the schema from being reparsed every time the environment variables are accessed, resulting in faster and more efficient code execution.

This change was inspired by a comment from the first post.

import type { CamelKeys, ReplaceKeys } from 'string-ts'
import { camelKeys, replaceKeys } from 'string-ts'

// Function to create a typed environment with caching.
function makeTypedEnvironment<T>(schema: (v: unknown) => T) {
  // Instantiate a cache to store parsed environment variables.
  let cache: CamelKeys<ReplaceKeys<T, 'VITE_', ''>>

  return (args: Record<string, unknown>) => {
    // If the environment variables are already cached, return the cached value.
    if (cache) return cache

    // Otherwise, parse the environment variables and transform the keys
    const withoutPrefix = replaceKeys(schema({ ...args }), 'VITE_', '')
    const camelCased = camelKeys(withoutPrefix)
    cache = camelCased
    return cache
  }
}
Enter fullscreen mode Exit fullscreen mode

You can add console.log around to see the cache in action.

Extending Schemas

We can leverage ArkType’s ability to extend schemas, simplifying the process of creating a superset of the public schema.

// environment.ts
import { type } from 'arktype'

// Define the public environment schema.
const publicEnvSchema = type({
  VITE_GOOGLE_MAPS_API_KEY: 'string',
  VITE_STRIPE_PUBLIC_KEY: 'string',
})
// Extend the public schema to create the full environment schema.
const envSchema = type(publicEnvSchema, '&', {
  MODE: ["'development'|'production'|'test'", '=', 'development'],
  SESSION_SECRET: 'string',
  STRIPE_SECRET_KEY: 'string',
})
// Create the environment parsers for public and full schemas.
const getPublicEnv = makeTypedEnvironment((d) =>
  publicEnvSchema.onUndeclaredKey('delete').assert(d)
)
const getEnv = makeTypedEnvironment((d) => envSchema.assert(d))
Enter fullscreen mode Exit fullscreen mode

This approach can also be done using Zod's .extend method for comparison.

import { z } from 'zod'

// Define the public environment schema.
const publicEnvSchema = z.object({
  VITE_GOOGLE_MAPS_API_KEY: z.string(),
  VITE_STRIPE_PUBLIC_KEY: z.string(),
})
// Extend the public schema to create the full environment schema.
const envSchema = publicEnvSchema.extend({
  MODE: z.enum(['development', 'production', 'test']).default('development'),
  SESSION_SECRET: z.string(),
  STRIPE_SECRET_KEY: z.string(),
})
// Create the environment parsers for public and full schemas.
const getPublicEnv = makeTypedEnvironment(
  publicEnvSchema.onUndeclaredKey('delete').parse,
)
const getEnv = makeTypedEnvironment(envSchema.parse)
Enter fullscreen mode Exit fullscreen mode

We are using ArkType's .onUndeclaredKey('delete') to remove undeclared keys from the object so we avoid exposing secrets to the client.

ArkType's default strategy is called Loose Assertion while Zod will omit undeclared keys by default - Safe Parsing.

Et Voilà

Thanks to Vite, we can go ahead and remove a bunch of code from the previous post.

Now, anywhere in your code, you can access the public environment variables in a type-safe way:

// app/routes/index.tsx
import { getPublicEnv } from '~/environment'

export default function Index() {
  function showStripeKey() {
    alert(
      `Stripe key on the client: ${
        getPublicEnv(import.meta.env).stripePublicKey
      }`,
    )
  }

  return (
    <div>
      <h1>
        GMAPS key on the server and client:{' '}
        {getPublicEnv(import.meta.env).googleMapsApiKey}
      </h1>
      <p>
        <button onClick={showStripeKey}>Alert Stripe key</button>
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

That's It

Now we have a faster, solid, type-safe environment variable system that works on both the client and server environments. And with a great DX:

Animated image showing the final DX

Another benefit of this approach is that you don't need to keep an .env.sample file as you can always check the environment.ts file to know what environment variables are required.

We also learned how to create functions that accept different parsers, which is a good practice, especially for library authors.

I'd love to hear your thoughts on this. If you have any questions or suggestions, please leave a comment below.

. . . . . . . . . . . . . . . . . . . . . . . . .