DEV Community

Cover image for schema-env v2.1: Now with Pluggable Validation Adapters (Joi, Yup, Your Own!)
Panupong Jaengaksorn
Panupong Jaengaksorn

Posted on

1

schema-env v2.1: Now with Pluggable Validation Adapters (Joi, Yup, Your Own!)

The Pain of Missing Env Vars

We've all been there: deploying a Node.js application only to have it crash immediately (or worse, subtly misbehave) because a crucial environment variable like DATABASE_URL was missing, empty, or malformed. Debugging configuration issues after deployment is frustrating and time-consuming. Manually checking process.env properties everywhere is tedious and error-prone.

What if you could guarantee your application's required environment variables are present and valid before any of your core application logic runs? What if you could get full type safety for your process.env object?

Introducing schema-env v2.1

That's exactly why I built schema-env! It's a lightweight Node.js library that loads your standard .env files, merges them with process.env, and validates the result against a schema at application startup. If validation fails, it throws a clear error, preventing your app from starting in an invalid state. If it succeeds, you get back a fully typed configuration object.

I'm excited to announce v2.1.0, which brings a major enhancement alongside a critical bug fix:

Pluggable Validation Adapters: You're no longer limited to Zod! While Zod remains the excellent default, you can now easily integrate Joi, Yup, or even your own custom validation logic using a simple adapter interface.
🐛 Precedence Bug Fix: Fixed a crucial bug ensuring process.env values now correctly override values loaded from .env files, matching the documented behavior.

Quick Start with Zod (Default)

Getting started is simple if you're using Zod:

1. Define your schema (src/env.ts)

import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z
    .enum(["development", "production", "test"])
    .default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url("Must be a valid PostgreSQL URL"),
  API_KEY: z.string().min(15, "API Key must be at least 15 characters"),
});

// Infer the type directly!
export type Env = z.infer<typeof envSchema>;
Enter fullscreen mode Exit fullscreen mode

2. Create your .env file

# .env
DATABASE_URL=postgresql://user:password@host:5432/db
API_KEY=your-super-secret-and-long-api-key
PORT=8080
Enter fullscreen mode Exit fullscreen mode

3. Validate at startup (src/index.ts)

import { createEnv } from "schema-env";
import { envSchema, type Env } from "./env.js"; // Use .js extension if needed

let env: Env;

try {
  // Pass the schema. Type is inferred!
  env = createEnv({ schema: envSchema });
  console.log("✅ Environment validated successfully!");
} catch (error) {
  // createEnv throws on validation failure
  // Error details are already logged to console.error
  console.error("❌ Fatal: Environment validation failed.");
  process.exit(1);
}

// Now use your type-safe 'env' object
console.log(`Running on port: ${env.PORT}`);
// await connectToDb(env.DATABASE_URL);
Enter fullscreen mode Exit fullscreen mode

If DATABASE_URL is missing or API_KEY is too short, createEnv throws, and your app exits cleanly.

New in v2.1: Bring Your Own Validator!

Want to use Joi, Yup, or something else? The new validator option makes it easy.

Example using Joi:

(See the full runnable example in the repo: examples/custom-adapter-joi/)

1. Define Joi Schema & TS Type (env.joi.ts)

import Joi from "joi";

// Define the TS type you expect after validation
export interface JoiEnv {
  API_HOST: string;
  API_PORT: number;
}

// Define the Joi schema
export const joiEnvSchema = Joi.object<JoiEnv, true>({
  API_HOST: Joi.string().hostname().required(),
  API_PORT: Joi.number().port().default(8080),
}).options({ abortEarly: false, allowUnknown: true, convert: true });
Enter fullscreen mode Exit fullscreen mode

2. Implement the Adapter (joi-adapter.ts)

import type { ObjectSchema } from "joi";
// Import types from schema-env
import type { ValidationResult, ValidatorAdapter, StandardizedValidationError } from "schema-env";

export class JoiValidatorAdapter<TResult> implements ValidatorAdapter<TResult> {
  constructor(private schema: ObjectSchema<TResult>) {}

  validate(data: Record<string, unknown>): ValidationResult<TResult> {
    const result = this.schema.validate(data);
    if (!result.error) {
      return { success: true, data: result.value as TResult };
    } else {
      // Map Joi errors to the standardized format
      const issues: StandardizedValidationError[] = result.error.details.map((d) => ({
        path: d.path,
        message: d.message,
      }));
      return { success: false, error: { issues } };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Use the Adapter (index.ts)

import { createEnv } from "schema-env";
import { JoiValidatorAdapter } from "./joi-adapter.js";
import { joiEnvSchema, type JoiEnv } from "./env.joi.js";

const adapter = new JoiValidatorAdapter(joiEnvSchema);

try {
  // Use 'validator' option and provide explicit types
  const env = createEnv<undefined, JoiEnv>({
    validator: adapter,
    // dotEnvPath options still work here too!
  });
  console.log("Validated with Joi:", env.API_HOST, env.API_PORT);
} catch (error) {
    console.error("❌ Fatal: Environment validation failed.");
    process.exit(1).
}
Enter fullscreen mode Exit fullscreen mode

Other Features

  • Async Secrets: Need to fetch secrets from AWS Secrets Manager, HashiCorp Vault, etc., at startup? Use createEnvAsync! It fetches from multiple sources concurrently before validation.

    const env = await createEnvAsync({
      schema, // or validator
      secretsSources: [fetchFromAWS, fetchFromVault],
    });
    
  • Flexible .env Loading: Handles .env, .env.${NODE_ENV}, loading from multiple paths (dotEnvPath: ['.env.shared', '.env.local']), and optional variable expansion via dotenv-expand (expandVariables: true).

  • Clear Precedence: process.env > Secrets (async only) > .env.${NODE_ENV} > dotEnvPath files > Schema/Adapter Defaults.

A Note on Development

This project was developed as an exploration of human-AI collaboration. I worked closely with an AI assistant (guided by specific instructions, project docs, and requirements) for tasks like generating boilerplate, drafting code/docs/tests, and refactoring. All code, fixes (like the precedence bug!), and final decisions were reviewed, directed, and tested by me (the human!). It's been an interesting process learning how to leverage these tools effectively in a structured project.

Try It Out!

Stop letting configuration errors ruin your deployments. Give schema-env a try!

Sentry image

Make it make sense

Only get the information you need to fix your code that’s broken with Sentry.

Start debugging →

Top comments (0)

Dev Diairies image

User Feedback & The Pivot That Saved The Project

🔥 Check out Episode 3 of Dev Diairies, following a successful Hackathon project turned startup.

Watch full video 🎥

👋 Kindness is contagious

Dive into this thoughtful piece, beloved in the supportive DEV Community. Coders of every background are invited to share and elevate our collective know-how.

A sincere "thank you" can brighten someone's day—leave your appreciation below!

On DEV, sharing knowledge smooths our journey and tightens our community bonds. Enjoyed this? A quick thank you to the author is hugely appreciated.

Okay