I Replaced dotenv With My Own Package — Here's Why You Should Too
Every week, the same story.
Friday deploy. CI passed. App went up.
Then a message in the group chat:
"the app is down"
You open the terminal, check the logs, and see this:
TypeError: Cannot read properties of undefined (reading 'split')
at DatabaseConnection.connect (database.js:42:30)
You start debugging.
Twenty minutes later, you find the root cause:
const url = process.env.DATABASE_URL // undefined
Someone forgot to add the variable on the production server.
That's it. One string. Took down the entire app.
I've been through this more times than I'd like to admit.
So I decided to stop accepting it as "normal" and build a real solution.
The Problem With dotenv
Don't get me wrong — dotenv is great.
- 45 million downloads per week
- Zero dependencies
- Does exactly what it promises
The problem is what it doesn't do.
require('dotenv').config()
const port = process.env.PORT // "3000" — a string, not a number
const debug = process.env.DEBUG // "true" — a string, not a boolean
const timeout = process.env.TIMEOUT // "30s" — a string, not milliseconds
const url = process.env.DATABASE_URL // undefined — nobody knows
dotenv loads your variables.
Period.
What you do with them is your problem.
And that's when the boilerplate starts — the code every Node developer writes in every project:
// 😩 The code we write in EVERY project
const port = parseInt(process.env.PORT || '3000', 10)
const debug = process.env.DEBUG === 'true'
const timeout = process.env.TIMEOUT
? parseInt(process.env.TIMEOUT.replace('s', '')) * 1000
: 30000
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required')
}
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is required')
}
This code has nothing to do with your business logic.
It's pure boilerplate.
And you rewrite it on every new project.
"But There's env-var..."
Yes, and it's decent.
But it has real problems.
1. Stops at the first error
// If DATABASE_URL is missing,
// you won't know PORT is also wrong
const port = env.get('PORT').required().asPortNumber()
const url = env.get('DATABASE_URL')
.required()
.asString() // ← error, stops here
const debug = env.get('DEBUG')
.required()
.asBool() // ← never reaches this
Instead of seeing all your problems at once, you discover them one by one.
Fix one, run again, another appears.
It's exhausting.
2. Verbose API
const port = env.get('PORT').required().asPortNumber()
const debug = env.get('DEBUG')
.default('false')
.asBool()
const url = env.get('DATABASE_URL')
.required()
.asUrlString()
const origins = env.get('ALLOWED_ORIGINS')
.required()
.asArray()
const timeout = env.get('TIMEOUT')
.default('30000')
.asIntPositive()
Do you really want to write this?
3. Weak TypeScript support
env-var doesn't infer the types of the final object.
You have to type everything manually or use type casting.
4. Doesn't load .env files
You still need dotenv installed alongside it.
What I Actually Wanted
I sat down and listed what an ideal solution should do:
✅ Load .env files without needing dotenv
✅ Validate ALL variables at once (not stop at the first error)
✅ Automatically coerce types
✅ Infer TypeScript types without manual casting
✅ Show a readable error when something is wrong
✅ Zero dependencies
I couldn't find anything that did all of this.
So I built it.
Introducing env-castle
npm install env-castle
The idea is simple:
You define a schema, and env-castle validates, coerces, and returns a fully typed object.
import { env } from 'env-castle'
const config = env({
NODE_ENV: {
type: 'enum',
values: ['development', 'staging', 'production'] as const,
default: 'development',
},
PORT: {
type: 'port',
default: 3000,
},
DATABASE_URL: {
type: 'url',
required: true,
desc: 'Get it from your database provider dashboard',
},
DEBUG: {
type: 'boolean',
default: false,
},
API_TIMEOUT: {
type: 'duration',
default: '30s',
},
ALLOWED_ORIGINS: {
type: 'list',
default: ['http://localhost:3000'],
},
})
And TypeScript knows exactly what each field is:
config.NODE_ENV
// 'development' | 'staging' | 'production'
config.PORT
// number
config.DATABASE_URL
// string
config.DEBUG
// boolean
config.API_TIMEOUT
// 30000
config.ALLOWED_ORIGINS
// string[]
Zero as.
Zero casting.
Zero parseInt.
Zero === 'true'.
What Happens When Something Is Wrong
This is where env-castle really stands out.
If you forget a variable or put in an invalid value, it won't let your app start.
You see this at boot time:
╔══════════════════════════════════════════════════════╗
║ ❌ ENV VALIDATION FAILED ║
╠══════════════════════════════════════════════════════╣
║ DATABASE_URL → missing (required) ║
║ ℹ Get it from your database ║
║ provider dashboard ║
║ PORT → "abc" is not a valid port ║
║ NODE_ENV → "test" is not one of: ║
║ development, staging, production ║
╠══════════════════════════════════════════════════════╣
║ 3 errors found. Fix your environment variables. ║
╚══════════════════════════════════════════════════════╝
All errors at once.
Not one at a time.
The app won't start.
The problem surfaces before any request comes in.
Not in production.
Not at 3am.
At boot time.
Supported Types
env-castle ships with 16 built-in types.
Basic types
{
NAME: { type: 'string', required: true },
COUNT: { type: 'integer', default: 0, min: 0, max: 100 },
RATE: { type: 'float', required: true },
ACTIVE: { type: 'boolean', default: true },
}
Infrastructure-specific types
{
PORT: { type: 'port', default: 3000 },
HOST: { type: 'host', default: 'localhost' },
API: { type: 'url', required: true },
EMAIL: { type: 'email', required: true },
}
My personal favorite: duration
{
CACHE_TTL: { type: 'duration', default: '5m' },
API_TIMEOUT: { type: 'duration', default: '30s' },
SESSION_TTL: { type: 'duration', default: '7d' },
}
Never write:
parseInt(process.env.TIMEOUT) * 1000
again.
Supported units:
ms, s, m, h, d, w
Lists
{
ALLOWED_ORIGINS: {
type: 'list',
default: ['http://localhost:3000']
},
FEATURE_FLAGS: {
type: 'list',
separator: '|',
default: []
},
ALLOWED_PORTS: {
type: 'list',
itemType: 'number',
required: true
},
}
Examples:
"a.com, b.com, c.com" → ['a.com', 'b.com', 'c.com']
"GET|POST|PUT" → ['GET', 'POST', 'PUT']
"3000,3001,3002" → [3000, 3001, 3002]
Enum with Literal Types
{
LOG_LEVEL: {
type: 'enum',
values: ['debug', 'info', 'warn', 'error'] as const,
default: 'info',
},
}
config.LOG_LEVEL
// 'debug' | 'info' | 'warn' | 'error'
Not just string.
The actual literal union type.
Prefix Groups
import { envGroup } from 'env-castle'
const db = envGroup('DB_', {
HOST: { type: 'host', default: 'localhost' },
PORT: { type: 'port', default: 5432 },
NAME: { type: 'string', required: true },
PASS: { type: 'string', required: true },
})
const redis = envGroup('REDIS_', {
URL: { type: 'url', required: true },
TTL: { type: 'duration', default: '5m' },
})
Reads:
DB_HOST
DB_PORT
DB_NAME
DB_PASS
Returns:
{
HOST,
PORT,
NAME,
PASS
}
without the prefix.
It Also Replaces dotenv
const { load } = require('env-castle')
// Drop-in replacement for dotenv
load()
const env = process.env.NODE_ENV || 'development'
load({
path: `.env.${env}`,
override: true,
})
Works with:
- Sequelize
- Knex
- TypeORM
- Prisma
- Any CLI tool
Real-World Setup
File Structure
my-project/
├── .env
├── .env.development
├── .env.production
├── .env.example
└── src/
└── config.ts
.env
NODE_ENV=development
DB_DIALECT=mysql
LOG_LEVEL=debug
.env.development
PORT=3000
DB_HOST=localhost
DB_NAME=myapp_dev
DB_USER=root
DB_PASS=root
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-key-min-32-characters-here
.env.production
PORT=8080
DB_HOST=db.myapp.com
DB_NAME=myapp
DB_USER=admin
DB_PASS=super_secure_password
REDIS_URL=redis://redis:6379
JWT_SECRET=production-secret-min-32-characters
Testing
import { describe, it, expect } from 'vitest'
import { envSafe, EnvValidationError } from 'env-castle'
describe('config', () => {
it('validates correctly', () => {
const config = envSafe({
PORT: { type: 'port', default: 3000 },
DEBUG: { type: 'boolean', default: false },
}, {
source: {
PORT: '8080',
DEBUG: 'true'
}
})
expect(config.PORT).toBe(8080)
expect(config.DEBUG).toBe(true)
})
it('catches ALL errors at once', () => {
try {
envSafe({
A: { type: 'string', required: true },
B: { type: 'url', required: true },
C: { type: 'port', required: true },
}, {
source: {}
})
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError)
expect(err.errors).toHaveLength(3)
}
})
})
Before vs After
❌ Before
require('dotenv').config()
const port = parseInt(process.env.PORT || '3000', 10)
const debug = process.env.DEBUG === 'true'
const timeout = process.env.TIMEOUT
? parseInt(process.env.TIMEOUT.replace('s', '')) * 1000
: 30000
const origins =
(process.env.ALLOWED_ORIGINS || 'http://localhost:3000')
.split(',')
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required')
}
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is required')
}
✅ After
import { env } from 'env-castle'
const config = env({
PORT: { type: 'port', default: 3000 },
DEBUG: { type: 'boolean', default: false },
TIMEOUT: { type: 'duration', default: '30s' },
ALLOWED_ORIGINS: { type: 'list', default: ['http://localhost:3000'] },
DATABASE_URL: { type: 'url', required: true },
JWT_SECRET: { type: 'string', required: true, minLength: 32 },
}, {
path: '.env'
})
- TypeScript infers everything ✅
- Zero boilerplate ✅
- Zero casting ✅
- All errors at once ✅
Comparison Table
| Feature | dotenv | env-var | t3-env | env-castle |
|---|---|---|---|---|
| Loads .env files | ✅ | ❌ | ❌ | ✅ |
| Load without validation | ✅ | ❌ | ❌ | ✅ |
| Type validation | ❌ | ✅ | ✅ | ✅ |
| Shows ALL errors at once | ❌ | ❌ | ❌ | ✅ |
| TypeScript inference | ❌ | ⚠️ | ✅ | ✅ |
| Beautiful error output | ❌ | ❌ | ❌ | ✅ |
| Duration (30s → ms) | ❌ | ❌ | ❌ | ✅ |
| List (a,b → array) | ❌ | ❌ | ❌ | ✅ |
| Prefix groups | ❌ | ❌ | ✅ | ✅ |
| Secrets masking | ❌ | ❌ | ❌ | ✅ |
| Zero dependencies | ✅ | ✅ | ❌ | ✅ |
| Works in any app | ✅ | ✅ | ❌ | ✅ |
What's Coming Next
I'm currently working on:
-
npx env-castle check— validates your.envfrom the terminal -
npx env-castle generate— generates a schema from.env.example - Watch mode — revalidates variables when files change
- Zod plugin — use your existing Zod schemas
Install
npm install env-castle
That's it.
Zero dependencies.
Works with:
- Express
- Fastify
- Hono
- NestJS
- Plain Node.js scripts
Anything that runs on Node.
📦 npm: npmjs.com/package/env-castle
⭐ GitHub: github.com/wallacefrota/env-castle
If you try it, let me know what you think in the comments.
Feedback, bugs, feature ideas — all welcome. 🏰
Top comments (1)
I think varlock.dev is what you have been looking for. Will solve your problems and a does a ton more - like loading from various backends, leak protection, log redaction, imports, drop in integrations. Check it out.