A TypeScript-first environment variable validation library, built with Zod for runtime validation and fully type-inferred.
- 🔒 Type-safe: Full TypeScript support with automatic type inference
- 🛡️ Runtime validation: Uses Zod schemas for robust validation
- 🌍 Universal: Works in Node.js, Vite, Next.js, and other environments
- 🔐 Security-first: Prevents server-side environment leaks to client
- 🎯 Framework agnostic: Can be used with any JavaScript framework
- ⚡ Zero dependencies: Only peer dependency on Zod
- 🧪 Well tested: Comprehensive test suite included
npm install @plaintooobject/env-core zod
# or
yarn add @plaintooobject/env-core zod
# or
pnpm add @plaintooobject/env-core zod
// env.ts
import { createNodeEnv, z } from '@plaintooobject/env-core';
export const env = createNodeEnv({
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
},
client: {
// Client vars must have CLIENT_ prefix for Node.js
CLIENT_API_URL: z.string().url(),
CLIENT_APP_NAME: z.string().default('My App'),
},
shared: {
APP_VERSION: z.string().default('1.0.0'),
},
});
// Usage
console.log(env.DATABASE_URL); // ✅ Fully typed and validated
console.log(env.PORT); // ✅ number (coerced from string)
console.log(env.CLIENT_API_URL); // ✅ Available everywhere
NOTE
If you do not use dotenv, make sure you add --env-file=
flag in your scripts. For example:
{
"scripts": {
"dev": "tsx --env-file=.env somewhere/your-entry-file.ts", // or
"start": "node --env-file=.env somewhere/your-entry-file.js"
}
}
// env.ts
import { createViteEnv, z } from '@plaintooobject/env-core';
export const env = createViteEnv({
server: {
// Server-only variables (not available in browser)
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
},
client: {
// Client variables (must have VITE_ prefix)
VITE_API_URL: z.string().url(),
VITE_APP_NAME: z.string().default('My Vite App'),
VITE_ENABLE_ANALYTICS: z.string().transform(val => val === 'true').default('false'),
},
shared: {
VITE_APP_VERSION: z.string().default('1.0.0'),
},
});
// In browser code:
console.log(env.VITE_API_URL); // ✅ Available in browser
console.log(env.VITE_APP_VERSION); // ✅ Available everywhere
// console.log(env.DATABASE_URL); // ❌ Error! Server-only var accessed in browser
// env.ts (or)
// src/env.ts
import { createEnv, z } from '@plaintooobject/env-core';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
},
shared: {
NODE_ENV: z.enum(['development', 'test', 'production']),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
NODE_ENV: process.env.NODE_ENV,
},
clientPrefix: 'NEXT_PUBLIC_',
});
The main function for creating a validated environment object.
server
(Record<string, ZodType>
, optional): Server-side environment variables schemaclient
(Record<string, ZodType>
, optional): Client-side environment variables schemashared
(Record<string, ZodType>
, optional): Variables available on both client and serverruntimeEnv
(Record<string, string | undefined>
): The actual environment object (e.g.,process.env
)clientPrefix
(string
, optional): Required prefix for client variables (default:"VITE_"
)isServer
(boolean
, optional): Whether running on server (default:typeof window === "undefined"
)onValidationError
(function
, optional): Custom validation error handleronInvalidAccess
(function
, optional): Custom invalid access error handlerskipValidation
(boolean
, optional): Skip validation (useful for build time)
Convenience function for Node.js environments.
import { createNodeEnv, z } from '@plaintooobject/env-core';
const env = createNodeEnv({
server: {
DATABASE_URL: z.string().url(),
},
client: {
CLIENT_API_URL: z.string().url(), // Uses CLIENT_ prefix
},
});
Convenience function for Vite environments.
import { createViteEnv, z } from '@plaintooobject/env-core';
const env = createViteEnv({
server: {
DATABASE_URL: z.string().url(),
},
client: {
VITE_API_URL: z.string().url(), // Uses VITE_ prefix
},
});
import { createEnv, z } from '@plaintooobject/env-core';
const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number(),
},
runtimeEnv: process.env,
onValidationError: (errors) => {
console.error('🚨 Environment validation failed:');
errors.forEach(err => {
console.error(` - ${err.path.join('.')}: ${err.message}`);
});
// Custom logic - maybe send to error reporting service
if (process.env.NODE_ENV === 'production') {
// Send to error reporting service
reportError('Environment validation failed', { errors });
}
process.exit(1);
},
onInvalidAccess: (message) => {
console.error('🔒 Security violation:', message);
throw new Error('Environment security violation');
},
});
import { createEnv, z } from '@plaintooobject/env-core';
const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
client: {
VITE_API_URL: z.string().url(),
},
runtimeEnv: process.env,
skipValidation: process.env.NODE_ENV === 'test', // Skip in tests
});
import { createEnv, z } from '@plaintooobject/env-core';
const env = createEnv({
server: {
// Transform comma-separated string to array
ALLOWED_ORIGINS: z
.string()
.transform(str => str.split(',').map(s => s.trim()))
.refine(arr => arr.length > 0, 'At least one origin required'),
// Parse JSON configuration
FEATURE_FLAGS: z
.string()
.transform(str => JSON.parse(str))
.pipe(z.record(z.boolean())),
// Validate and transform log level
LOG_LEVEL: z
.enum(['debug', 'info', 'warn', 'error'])
.default('info'),
// Custom validation with refine
API_KEY: z
.string()
.refine(key => key.startsWith('sk-'), 'API key must start with sk-')
.refine(key => key.length >= 32, 'API key too short'),
},
runtimeEnv: process.env,
});
// Usage with full type safety
const origins: string[] = env.ALLOWED_ORIGINS;
const features: Record<string, boolean> = env.FEATURE_FLAGS;
const logLevel: 'debug' | 'info' | 'warn' | 'error' = env.LOG_LEVEL;
Create a .env
file in your project root:
# .env
# Server-only variables
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=your-super-secret-jwt-key-that-is-at-least-32-chars
OPENAI_API_KEY=sk-your-openai-api-key-here
REDIS_URL=redis://localhost:6379
# Client variables (must be prefixed for security)
VITE_API_URL=http://localhost:3000/api
VITE_APP_NAME=My Awesome App
VITE_ENABLE_ANALYTICS=false
VITE_GA_TRACKING_ID=G-XXXXXXXXXX
# Shared variables
NODE_ENV=development
APP_VERSION=1.0.0
PORT=3000
# Complex configurations
ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com,https://staging.myapp.com
FEATURE_FLAGS={"newDashboard":true,"betaFeatures":false}
LOG_LEVEL=debug
const env = createEnv({
server: {
PORT: z.coerce.number(), // Inferred as number
ENABLE_SSL: z.string().transform(val => val === 'true'), // Inferred as boolean
ALLOWED_HOSTS: z.array(z.string()), // Inferred as string[]
CONFIG: z.object({
timeout: z.number(),
retries: z.number(),
}), // Inferred as { timeout: number; retries: number }
},
runtimeEnv: process.env,
});
// All properly typed without manual type annotations
const port: number = env.PORT;
const sslEnabled: boolean = env.ENABLE_SSL;
const hosts: string[] = env.ALLOWED_HOSTS;
const config: { timeout: number; retries: number } = env.CONFIG;
const env = createEnv({
server: { DB_URL: z.string() },
client: { VITE_API: z.string() },
runtimeEnv: process.env,
});
// TypeScript will catch these errors:
// env.NONEXISTENT; // ❌ Property 'NONEXISTENT' does not exist
// env.DB_URL = 'new-url'; // ❌ Cannot assign to read-only property
The library prevents accidental exposure of server-side secrets to client-side code:
const env = createEnv({
server: {
DATABASE_URL: z.string(),
JWT_SECRET: z.string(),
},
client: {
VITE_API_URL: z.string(),
},
runtimeEnv: import.meta.env,
isServer: false, // Running in browser
});
console.log(env.VITE_API_URL); // ✅ Works fine
console.log(env.DATABASE_URL); // ❌ Throws error - server var accessed on client
Client-side environment variables must be properly prefixed:
createEnv({
client: {
API_URL: z.string(), // ❌ Error! Must start with VITE_
VITE_API_URL: z.string(), // ✅ Correct
},
runtimeEnv: import.meta.env,
});
// server/env.ts
import { createNodeEnv, z } from '@plaintooobject/env-core';
export const env = createNodeEnv({
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
},
});
// server/app.ts
import express from 'express';
import { env } from './env';
const app = express();
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
});
// src/env.ts
import { createViteEnv, z } from '@plaintooobject/env-core';
export const env = createViteEnv({
client: {
VITE_API_URL: z.string().url(),
VITE_APP_NAME: z.string().default('My React App'),
VITE_ENABLE_ANALYTICS: z.string().transform(val => val === 'true'),
},
});
// src/services/api.ts
import { env } from '../env';
export const apiClient = axios.create({
baseURL: env.VITE_API_URL, // Fully typed and validated
});
// src/App.tsx
import { env } from './env';
function App() {
return (
<div>
<h1>{env.VITE_APP_NAME}</h1>
{env.VITE_ENABLE_ANALYTICS && <AnalyticsProvider />}
</div>
);
}
// test/setup.ts
import { beforeAll } from 'vitest';
import { createEnv, z } from '@plaintooobject/env-core';
beforeAll(() => {
// Mock environment for tests
process.env.DATABASE_URL = 'postgresql://localhost:5432/test';
process.env.JWT_SECRET = 'test-secret-key-for-testing-only';
});
// test/env.test.ts
import { createEnv, z } from '@plaintooobject/env-core';
describe('Environment', () => {
it('should validate test environment', () => {
const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string(),
},
runtimeEnv: {
DATABASE_URL: 'postgresql://localhost:5432/test',
JWT_SECRET: 'test-key',
},
skipValidation: false, // Always validate in tests
});
expect(env.DATABASE_URL).toBe('postgresql://localhost:5432/test');
});
});
// Before - Manual validation
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is required');
}
const PORT = parseInt(process.env.PORT || '3000');
// After - Schema-based validation
import { createNodeEnv, z } from '@plaintooobject/env-core';
export const env = createNodeEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
},
});
Create one env.ts
file and export it for use throughout your application:
// env.ts - Single source of truth
import { createEnv, z } from '@plaintooobject/env-core';
export const env = createEnv({
// All your env vars here
});
// Other files
import { env } from './env';
// ❌ Not descriptive
VITE_URL=https://api.example.com
VITE_KEY=abc123
// ✅ Descriptive
VITE_API_BASE_URL=https://api.example.com
VITE_STRIPE_PUBLIC_KEY=pk_test_abc123
const env = createEnv({
server: {
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
},
});
const env = createEnv({
server: {
// Parse URLs
ALLOWED_ORIGINS: z
.string()
.transform(str => str.split(','))
.pipe(z.array(z.string().url())),
// Parse JSON
FEATURE_FLAGS: z
.string()
.transform(str => JSON.parse(str))
.pipe(z.record(z.boolean())),
// Boolean parsing
ENABLE_HTTPS: z
.string()
.transform(val => val.toLowerCase() === 'true'),
},
});
Call your env validation as early as possible in your application:
// index.ts or main.ts
import './env'; // This will validate on import
// Now start your app
import './app';
Q: Getting "Invalid client environment variable" error
A: Make sure client variables have the correct prefix (VITE_, NEXT_PUBLIC_, etc.)
// ❌ Wrong
client: {
API_URL: z.string(), // Missing prefix
}
// ✅ Correct
client: {
VITE_API_URL: z.string(),
}
Q: Server variables are undefined in the browser
A: This is expected behavior for security. Server variables are not available in client code.
Q: Validation passes but values are still undefined
A: Check your runtimeEnv
- make sure you're passing the correct environment object:
// Node.js
runtimeEnv: process.env
// Vite
runtimeEnv: import.meta.env
// Custom
runtimeEnv: {
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
VITE_CLERK_PUBLISHABLE_KEY: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
}
Enable debug logging to see what's happening:
const env = createEnv({
// ... your config
onValidationError: (errors) => {
console.log('Runtime env:', runtimeEnv);
console.log('Validation errors:', errors);
throw new Error('Validation failed');
},
});
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature
- Make your changes and add tests
- Run tests:
npm test
- Commit your changes:
git commit -m 'Add amazing feature'
- Push to the branch:
git push origin feature/amazing-feature
- Open a Pull Request
MIT © LICENSE