← Projects

e·n·v

Interactive setup and validation for environment variables

2025  ·  TypeScript, Clack  ·  Open source; published to npm

I love text UIs. I don’t love authoring .env files by hand. So… I made a text UI for authoring .env files! I call it e·n·v. It’s common practice to validate environment variables at application startup via libraries like T3 Env and znv, and I figured: If we have enough information to validate environment variables, do we have enough to ask the developer for them? The answer turned out to be… sort of. But first, give it a try!

Live demo
Terminal
.env
A few things to try:
  1. Input an invalid value in the terminal, such as none for PORT.
  2. Add comments after values in the .env content (start them with #).
  3. Set an invalid value directly in the .env content (for example, NODE_ENV=lol).

I was able to get something basic up and running pretty quickly thanks to the excellent terminal infrastructure provided by Clack and the robust validation logic from Zod. But it started becoming clear that there were way too many concerns being handled within the UI layer. Thus, approximately ten refactors later… I ended up with a highly modular suite of packages, chief among them:

  • prompt: Interactive prompt-based CLI
  • parse: Environment variable parsing and validation
  • schemas: Reusable schemas for common environment variables
  • files: Read/write API for .env files
  • channels: Integration layer with environment managers
  • converters: Converters for external schema formats

When the dust settled, basic usage looked something like the following. A model file (e.g. env.model.ts) contains the shared definition used both during authoring and application startup.

import { vars } from "@e-n-v/env";
import { PORT, NODE_ENV, API_KEY } from "@e-n-v/env/schemas";

export default vars({ NODE_ENV, PORT, API_KEY });

A variables file (e.g. env.vars.ts) parses the model at runtime.

import { parse } from "@e-n-v/env";
import model from "./env.model";

export const { PORT, NODE_ENV, API_KEY } = parse(process.env, model);

And a setup file (e.g. env.setup.ts) triggers interactive setup. (There’s also a dedicated e-n-v CLI entry point.)

import { prompt } from "@e-n-v/env";
import dotenvx from "@dotenvx/dotenvx";
import model from "./env.model";

await prompt(model, {
  channel: { dotenvx },
});

Throughout the project, Clack remained the most essential dependency, though I outgrew its prebuilt prompts before long. I wanted interactions with default and custom options. I wanted a toolbar for extra commands. I wanted an undo feature. I wanted a lot! Fortunately they also provide access to lower-level plumbing, which I then augmented with a mere two hundred-line state machine and only modest hackery.

I also got quite good mileage out of Zod, but eventually realized there was metadata useful for interactive authoring that didn’t really have a place in a pure validation library, particularly URLs — Environment variable secrets like API keys tend to come from websites, and those are easy to forget. This led me to creating a more generic way to define details and expectations for variables, which looks something like this:

const API_KEY = s.string({
  description: "The API key for authenticating requests.",
  required: true,
  secret: true,
  link: "https://example.com/settings/api-keys",
  process: string(minLength(1)),
});

And with that in place… I realized I could map in other validation libraries such as Joi (which I have a soft spot for) and Superstruct. You might be wondering whether I could’ve used Standard Schema, which provides a shared contract for validation libraries. Unfortunately not, because what I needed wasn’t validation per se, it was reflection. I needed to know the expected shape of a value without submitting an actual value, so that I could prompt the user for it. This meant writing a separate adapter layer for each schema library, and it also meant that some schema libraries couldn’t be robustly supported since they didn’t expose enough information.

The most unpleasant surprise of this project was that I couldn’t find a great solution for writing .env file values. It’s one of those things I figured surely was a solved problem, but apparently not. Of course, rendering a flat object of key-value pairs into an INI-like format isn’t challenging… unless you want to preserve comments. And newlines. And order, and duplicate values… and then it actually kind of is. So, even though I started this project to design UI, I ended up designing a parser. (Fortunately, I’m also quite fond of parsers, and I enjoyed the detour.)

That said, about halfway through development, I discovered dotenvx and realized it broke all of my assumptions about writing to files anyway. Don’t get me wrong, it’s an awesome product; it just revealed a strong coupling I hadn’t even realized I’d made. This led me to the “channels” abstraction, where any provider with getters and setters could integrate with the interactive prompting layer (async, naturally, because disk and network).

export interface EnvChannel {
  get(keys?: string[]): Promise<Record<string, string>>;
  set(values: Record<string, string>): Promise<void>;
}

The main piece of functionality I didn’t touch with this project was runtime value injection. There are battle-tested options for that, and thus there was no need to. I also decided not to pursue a full formatter, tempting as it was. The subtle semantics of env files just weren’t clear enough to me: should blank lines be persisted? What if they’re between sections? Is “sections” even a thing? Is there a difference between a “comment” and a “commented out value”? Maybe I’ll come back to that, but for the time being, I was satisfied with the other modules.

I find a certain delight in text UIs, something about their universality, economy of interaction, and visual sincerity. I don’t think they’re right for everything, and I find workflows should graduate to full graphical (or other) interfaces when they reach a certain degree of complexity. But for authoring .env files, they hit the spot. You can check out the source code on GitHub and give it a try via npm!