Type-Safe Env Vars With Vite - A Modern Approach
Tags: typescript, remix, advanced, vite
Published: 2024-06-14 12:49:46

This post is a revamp of my [previous one](https://dev.to/seasonedcc/type-safe-environment-variables-on-both-client-and-server-with-remix-54l5) 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](https://vitejs.dev/guide/env-and-mode), and use [my new friend](https://dev.to/seasonedcc/using-arktype-in-place-of-zod-how-to-adapt-parsers-3bd5) - [ArkType](https://github.com/arktypeio/arktype) - to parse the environment variables this time.

> If you only want to see the code, check out the [updates' diff here](https://github.com/gustavoguichard/remix-public-env/pull/1).

## The Gist of Changes

By updating Remix from 1.7 to 2.9 and using [Vite](https://vitejs.dev) 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](https://github.com/colinhacks/zod) to [ArkType](https://github.com/arktypeio/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.

```ts
// 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)
}
```

We can use it in both server or client:

```ts
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' }
```

> You could also use `getEnv(window.ENV)` if you are not using Vite, just follow the instructions on the [previous post](https://dev.to/seasonedcc/type-safe-environment-variables-on-both-client-and-server-with-remix-54l5).

### Accepting Different Parsers and Preventing Mutations

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

```ts
// 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 })
}
```

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.

```ts
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))
```

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..

```ts
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 }))
}
```

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

```ts
// 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 }
```

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](https://vitejs.dev/guide/env-and-mode#env-files) and you can [change the prefix](https://vitejs.dev/config/shared-options.html#envprefix) if you want.

We can go further and remove that prefix as follows:

```ts
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_', ''))
}
```

Check out the result:
```ts
// 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 }
```

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](https://dev.to/creeefs/comment/25icc).

```ts
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
  }
}
```

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.

```ts
// 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))
```

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

```ts
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)
```

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](https://github.com/gustavoguichard/remix-public-env/pull/1/files) from the previous post.

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

```tsx
// 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>
  )
}
```

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ddf2ssw3p7x85i2fafat.gif)

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.