I Didn't Install dotenv: What Happened When We Rebuilt It From Scratch

javascript dev.to

I Didn't Install dotenv: What Happened When We Rebuilt It From Scratch

Every Node.js developer has probably done this:

npm install dotenv
Enter fullscreen mode Exit fullscreen mode

Need validation?

Install another package.

Need environment variable expansion?

Install another package.

It works. It's convenient. And most of the time, we never stop to think about what those packages are actually doing underneath.

For Zero Dependency 2026, we decided to ask a different question:

How much of this could we build ourselves using only Node.js?

So we built envkit — a zero-dependency environment configuration and validation tool using only Node.js built-in APIs.

The problem

A typical Node.js project might use packages for several parts of environment configuration:

  • Reading .env files
  • Expanding variables such as ${HOST}
  • Validating required configuration
  • Providing useful command-line feedback

For our project, none of those external runtime packages were allowed.

That meant we couldn't simply install the solution.

We had to understand what the solution actually does.

What we decided to build

Our goal was to create a small command-line tool that could:

.env
  ↓
Parse
  ↓
Expand variables
  ↓
Validate
  ↓
Run the application
Enter fullscreen mode Exit fullscreen mode

The main commands are:

envkit check
Enter fullscreen mode Exit fullscreen mode

and:

envkit run -- node app.js
Enter fullscreen mode Exit fullscreen mode

The first checks whether the environment configuration is valid.

The second validates the environment before starting the application.

Rebuilding the part I normally get from dotenv

Our first challenge was parsing .env.

A simple file looks easy:

PORT=3000
HOST=localhost
API_KEY=abc123
Enter fullscreen mode Exit fullscreen mode

Reading the file itself wasn't difficult. Node's standard library already provides the filesystem functionality we needed.

The interesting part was everything around it.

We had to decide how our parser would handle:

  • Blank lines
  • Comments
  • Whitespace
  • Quoted values
  • Escaped characters
  • Invalid lines
  • Multiline values

The lesson was simple:

Reading a file is easy. Correctly interpreting a configuration format is where the complexity begins.

Instead of importing a parser, we implemented that logic ourselves.

Then variable expansion looked easy

We wanted to support configuration such as:

HOST=localhost
PORT=3000
API_URL=http://${HOST}:${PORT}/api
Enter fullscreen mode Exit fullscreen mode

The expected result is:

API_URL=http://localhost:3000/api
Enter fullscreen mode Exit fullscreen mode

At first, this looked like a simple string replacement problem.

Then we considered:

A=${B}
B=${A}
Enter fullscreen mode Exit fullscreen mode

A naive implementation can keep resolving the variables forever.

So we needed to track which variables were currently being resolved.

Instead of hanging, envkit can report:

Circular reference detected: A → B → A
Enter fullscreen mode Exit fullscreen mode

This was one of the moments where rebuilding a familiar feature became much more interesting than simply using a package.

A feature that looked like "replace ${VARIABLE}" suddenly involved dependency resolution and cycle detection.

Rebuilding validation

The next part was environment validation.

We wanted developers to be able to describe requirements such as:

{
    PORT: {
        type: "number",
        required: true
    },

    API_KEY: {
        type: "string",
        required: true
    },

    NODE_ENV: {
        type: "enum",
        values: ["development", "production"],
        default: "development"
    }
}
Enter fullscreen mode Exit fullscreen mode

Then a configuration such as:

PORT=hello
Enter fullscreen mode Exit fullscreen mode

should produce something useful:

✗ PORT must be a number
Enter fullscreen mode Exit fullscreen mode

Instead of installing a validation library, we implemented the checks using JavaScript's built-in language features.

The surprising part wasn't checking a type.

The difficult part was designing good error messages.

A validator that simply says "invalid" isn't very helpful.

We wanted the developer to immediately understand:

  • Which variable failed
  • Why it failed
  • What was expected
  • Whether multiple variables were incorrect

The CLI

Once the individual pieces worked, we connected them through a command-line interface.

The intended workflow became:

envkit check
Enter fullscreen mode Exit fullscreen mode

which performs:

Read .env
   ↓
Parse
   ↓
Expand
   ↓
Validate
   ↓
Show result
Enter fullscreen mode Exit fullscreen mode

And:

envkit run -- node app.js
Enter fullscreen mode Exit fullscreen mode

performs the same validation before starting the application.

If validation fails:

✗ Environment validation failed.

Application was not started.
Enter fullscreen mode Exit fullscreen mode

If it succeeds:

✓ Environment valid
✓ Starting application...
Enter fullscreen mode Exit fullscreen mode

That turned the project from a collection of small utilities into an actual developer tool.

The question of trust

One of the questions we kept coming back to was:

Why should someone trust our implementation instead of the packages we replaced?

The answer isn't "because we're better."

That would just replace one form of blind trust with another.

The more interesting idea is verifiable trust.

A developer can inspect our relatively small codebase and understand what it does.

There isn't a large dependency tree hiding behind the application.

There is no external runtime package that can silently become part of the application because another package pulled it in.

Our goal isn't to eliminate trust.

It's to make the trusted surface small enough to inspect.

What we learned

The biggest lesson wasn't that npm packages are bad.

They aren't.

Mature packages exist because developers have already spent years discovering and fixing edge cases.

Our experiment showed us why those abstractions are useful.

When you remove the package, you suddenly encounter the complexity it was hiding:

dotenv
   ↓
Parsing rules

dotenv-expand
   ↓
Variable resolution
   ↓
Circular references

Validation library
   ↓
Type checking
   ↓
Defaults
   ↓
Error reporting
Enter fullscreen mode Exit fullscreen mode

The standard library gave us the primitives.

But the engineering decisions were ours.

What we don't claim

envkit is not intended to pretend that a few hundred lines of code can instantly replace years of work in mature production libraries.

We will document the features we support and the edge cases we intentionally don't support.

That honesty is part of the project.

A zero-dependency project shouldn't hide its limitations just to look impressive.

Proving the dependency claim

The final project should make the zero-dependency requirement easy to verify.

Our Node.js project has no third-party runtime dependencies.

Instead of asking a judge to trust our claim, we provide dependency proof and document the standard-library substitutions in STDLIB.md.

For example:

Package we would normally use → What we used instead

dotenv        → Node filesystem + our parser
dotenv-expand → Our variable resolver
zod/envalid   → Our validation engine
chalk         → ANSI terminal escape sequences
Jest          → node:test + node:assert
Enter fullscreen mode Exit fullscreen mode

The point isn't simply to have an empty package.json.

The point is to explain what we built instead.

Final thoughts

Zero Dependency forced us to slow down and look underneath abstractions we normally take for granted.

We started with:

npm install dotenv
Enter fullscreen mode Exit fullscreen mode

and ended up asking:

What does dotenv actually have to do?

Then we asked the same question about expansion, validation, and CLI tooling.

The result is envkit: a small Node.js developer tool built without third-party runtime dependencies.

The most valuable part wasn't removing the packages.

It was discovering why those packages exist in the first place.

Source: dev.to

arrow_back Back to Tutorials