Type-Safe Env Vars in Remix – A modern approach with ArkType

RMAG news

This post is a continuation 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’s the foundation 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, 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. Take a closer look at the second commit on that PR.

The Gist of Changes

Aside from updating Remix (from 1.7 to 2.9) and switching from Zod to ArkType, I’ve introduced a makeTypedEnvironment helper to streamline handling environment variables in both server and client environments. Additionally, I’ve optimized the codebase by removing the typedPick method and refining imports to ensure server code doesn’t leak into the client bundle.

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

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({
NODE_ENV: 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)
// ^? { NODE_ENV: ‘development’ }

// Client usage: parse environment variables from window.ENV
const env = getEnv(window.ENV)
// ^? { NODE_ENV: ‘development’ }

// Vite client-side env vars usage: parse environment variables from import.meta.env
const env = getEnv(import.meta.env)
// ^? { NODE_ENV: ‘development’ }

If you didn’t understand where the window.ENV comes from, don’t forget to read 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 })
}

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({
NODE_ENV: 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({
NODE_ENV: [“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..

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 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 envSchema = type({
NODE_ENV: [“development”|”test”|”production”, =, development],
})
// 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)
// ^? { nodeEnv: ‘development’ | ‘test’ | ‘production’ }

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.

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 } from string-ts
import { camelKeys } 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.
const cache = new Map<unknown, CamelKeys<T>>()

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

// Otherwise, parse the environment variables, cache the result, and return it.
const parsed = camelKeys(schema({ args }))
cache.set(args, parsed)
return parsed
}
}

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

Removing the typedPick by Extending Schemas

Instead of using a custom typedPick method, we can leverage ArkType’s ability to extend schemas, simplifying the process of creating a subset of the schema.

// environment.ts
import { type } from arktype

// Define the public environment schema.
const publicEnvSchema = type({
GOOGLE_MAPS_API_KEY: string,
STRIPE_PUBLIC_KEY: string,
})
// Extend the public schema to create the full environment schema.
const envSchema = type(publicEnvSchema, &, {
NODE_ENV: [‘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.assert(d))
const getEnv = makeTypedEnvironment((d) => envSchema.assert(d))

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({
GOOGLE_MAPS_API_KEY: z.string(),
STRIPE_PUBLIC_KEY: z.string(),
})
// Extend the public schema to create the full environment schema.
const envSchema = publicEnvSchema.extend({
NODE_ENV: 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.parse)
const getEnv = makeTypedEnvironment(envSchema.parse)

Moving Files Around

The original post had a little bit of server code in the client bundle. Now that Remix uses Vite, it was easy to spot that.

That is why I removed the environment.server.ts file and created a environment.ts file that can be imported anywhere.

I’m not going to explain the whole PR diff, but here follows some important highlights.

The app/business/public-env.server.ts File

This file is the only server-only file now. It is called in the root’s loader and is responsible for loading the public env vars.

It uses .onUndeclaredKey(‘delete’) to remove any undeclared key from the env vars. This is important because we don’t want to expose any secret over the wire. If you are using Zod, you wouldn’t need to do that because Zod omits undeclared keys by default.

// app/business/public-env.server.ts
import { publicEnvSchema } from ~/environment

// Function to load public environment variables on the server.
const loadPublicEnv = () =>
publicEnvSchema.onUndeclaredKey(delete).assert({ process.env })

export { loadPublicEnv }

You can notice we are not using the getPublicEnv function because we don’t want the casing transformation in the window.ENV object. The idea is that window.ENV and process.env should be similar to be used as source in the file below.

The app/ui/public-env.tsx File

By avoiding the string transformation in the window.ENV object and with a couple changes in this file, we made the following lines possible:

// Depending on the environment, we use the right source of environment variables.
const source = typeof window === undefined ? process.env : window.ENV
return getPublicEnv(source)[key]

That’s It

Now we have a faster, solid, type-safe environment variable system that works on both the client (from the window or the bundler) and server environments.

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.