Skip to content

plaintoobject/env-core

Repository files navigation

@plaintooobject/env-core

A TypeScript-first environment variable validation library, built with Zod for runtime validation and fully type-inferred.

Features

  • 🔒 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

Installation

npm install @plaintooobject/env-core zod
# or
yarn add @plaintooobject/env-core zod
# or
pnpm add @plaintooobject/env-core zod

Quick Start

Node.js Usage

// 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"
    }
}

Vite Usage

// 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

Next.js Usage

// 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_',
});

API Reference

createEnv(options)

The main function for creating a validated environment object.

Options

  • server (Record<string, ZodType>, optional): Server-side environment variables schema
  • client (Record<string, ZodType>, optional): Client-side environment variables schema
  • shared (Record<string, ZodType>, optional): Variables available on both client and server
  • runtimeEnv (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 handler
  • onInvalidAccess (function, optional): Custom invalid access error handler
  • skipValidation (boolean, optional): Skip validation (useful for build time)

createNodeEnv(options)

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
  },
});

createViteEnv(options)

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
  },
});

Advanced Usage

Custom Error Handling

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');
  },
});

Skip Validation for Testing

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
});

Complex Transformations

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;

Environment File Example

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

Type Safety Features

Automatic Type Inference

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;

Compile-Time Safety

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

Security Features

Server/Client Variable Separation

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 Variable Prefix Enforcement

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,
});

Integration Examples

With Express.js

// 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}`);
});

With React + Vite

// 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>
  );
}

With Testing

// 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');
  });
});

Migration Guide

From Manual Environment Handling

// 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),
  },
});

Best Practices

1. Single Environment File

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';

2. Use Descriptive Variable Names

// ❌ 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

3. Provide Sensible Defaults

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'),
  },
});

4. Use Transformations for Complex Types

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'),
  },
});

5. Validate Early

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';

Troubleshooting

Common Issues

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
}

Debug Mode

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');
  },
});

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/amazing-feature
  3. Make your changes and add tests
  4. Run tests: npm test
  5. Commit your changes: git commit -m 'Add amazing feature'
  6. Push to the branch: git push origin feature/amazing-feature
  7. Open a Pull Request

License

MIT © LICENSE

About

A TypeScript-first environment variable validation library

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published