DEV Community

Cover image for I Replaced dotenv With My Own Package — Here's Why You Should Too
FrotaDev
FrotaDev

Posted on

I Replaced dotenv With My Own Package — Here's Why You Should Too

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)
Enter fullscreen mode Exit fullscreen mode

You start debugging.

Twenty minutes later, you find the root cause:

const url = process.env.DATABASE_URL // undefined
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I couldn't find anything that did all of this.

So I built it.


Introducing env-castle

npm install env-castle
Enter fullscreen mode Exit fullscreen mode

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'],
  },
})
Enter fullscreen mode Exit fullscreen mode

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[]
Enter fullscreen mode Exit fullscreen mode

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.     ║
╚══════════════════════════════════════════════════════╝
Enter fullscreen mode Exit fullscreen mode

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 },
}
Enter fullscreen mode Exit fullscreen mode

Infrastructure-specific types

{
  PORT:  { type: 'port', default: 3000 },
  HOST:  { type: 'host', default: 'localhost' },
  API:   { type: 'url', required: true },
  EMAIL: { type: 'email', required: true },
}
Enter fullscreen mode Exit fullscreen mode

My personal favorite: duration

{
  CACHE_TTL:   { type: 'duration', default: '5m' },
  API_TIMEOUT: { type: 'duration', default: '30s' },
  SESSION_TTL: { type: 'duration', default: '7d' },
}
Enter fullscreen mode Exit fullscreen mode

Never write:

parseInt(process.env.TIMEOUT) * 1000
Enter fullscreen mode Exit fullscreen mode

again.

Supported units:

ms, s, m, h, d, w
Enter fullscreen mode Exit fullscreen mode

Lists

{
  ALLOWED_ORIGINS: {
    type: 'list',
    default: ['http://localhost:3000']
  },

  FEATURE_FLAGS: {
    type: 'list',
    separator: '|',
    default: []
  },

  ALLOWED_PORTS: {
    type: 'list',
    itemType: 'number',
    required: true
  },
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

Enum with Literal Types

{
  LOG_LEVEL: {
    type: 'enum',
    values: ['debug', 'info', 'warn', 'error'] as const,
    default: 'info',
  },
}
Enter fullscreen mode Exit fullscreen mode
config.LOG_LEVEL
// 'debug' | 'info' | 'warn' | 'error'
Enter fullscreen mode Exit fullscreen mode

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' },
})
Enter fullscreen mode Exit fullscreen mode

Reads:

DB_HOST
DB_PORT
DB_NAME
DB_PASS
Enter fullscreen mode Exit fullscreen mode

Returns:

{
  HOST,
  PORT,
  NAME,
  PASS
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.env

NODE_ENV=development
DB_DIALECT=mysql
LOG_LEVEL=debug
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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)
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

✅ 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'
})
Enter fullscreen mode Exit fullscreen mode
  • 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 .env from 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
theoephraim profile image
Theo Ephraim

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.