Type-safe Env

beginner

Runtime-validated environment variables with Zod and TypeScript.

environmentzodtypescriptvalidation
Tested on201619TS5.9
$ bunx sinew add environment/type-safe-env

1The Problem

Environment variables are a common source of runtime errors:

  • Typos in variable names go unnoticed
  • Missing required variables crash in production
  • No type safety or IDE autocomplete
  • No validation of expected formats

2The Solution

Use Zod to validate and type environment variables at startup.

3Files

lib/env.ts

lib/env.tsTypeScript
import { z } from "zod";

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url(),
  DIRECT_URL: z.string().url().optional(),

  // Auth
  AUTH_SECRET: z.string().min(32),
  GITHUB_CLIENT_ID: z.string().optional(),
  GITHUB_CLIENT_SECRET: z.string().optional(),

  // API Keys
  STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(),
  RESEND_API_KEY: z.string().startsWith("re_").optional(),

  // App
  NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

// Validate at module load
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("Invalid environment variables:");
  console.error(parsed.error.flatten().fieldErrors);
  throw new Error("Invalid environment variables");
}

export const env = parsed.data;

// Type export for use elsewhere
export type Env = z.infer<typeof envSchema>;

lib/env.client.ts

lib/env.client.tsTypeScript
import { z } from "zod";

// Client-side env vars must be prefixed with NEXT_PUBLIC_
const clientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
  NEXT_PUBLIC_POSTHOG_HOST: z.string().url().optional(),
});

export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
  NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
});

.env.example

.env.exampleBash
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# Auth (generate with: openssl rand -base64 32)
AUTH_SECRET="your-32-character-secret-here-minimum"

# Optional: OAuth
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""

# Optional: Payments
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

# Optional: Email
RESEND_API_KEY="re_..."

# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NODE_ENV="development"

4Dependencies

$ bun add zod

5Configuration

Schema Options

Zod provides many validation helpers:

z.string().url(); // Must be valid URL
z.string().email(); // Must be valid email
z.string().min(32); // Minimum length
z.string().startsWith("sk_"); // Must start with prefix
z.enum(["a", "b", "c"]); // Must be one of these values
z.string().optional(); // Not required
z.string().default("foo"); // Default value if missing
z.coerce.number(); // Parse string to number
z.coerce.boolean(); // Parse "true"/"false" to boolean
TypeScript

6Usage

Import and Use

import { env } from "@/lib/env";

// Full type safety and autocomplete
const dbUrl = env.DATABASE_URL;
const isProduction = env.NODE_ENV === "production";

// TypeScript error if accessing non-existent var
const invalid = env.DOES_NOT_EXIST; // TS Error!
TypeScript

Client Components

"use client";

import { clientEnv } from "@/lib/env.client";

// Only NEXT_PUBLIC_ vars are available
const appUrl = clientEnv.NEXT_PUBLIC_APP_URL;
TypeScript

7Troubleshooting

"Invalid environment variables" on startup

Check the console output for which variables are missing or invalid. Common issues:

  • Missing required variables
  • Invalid URL format (missing protocol)
  • Wrong prefix (e.g., sk_live_ vs sk_test_)

Client variables undefined

Make sure client-side variables are prefixed with NEXT_PUBLIC_ and rebuild your app after adding new ones.

8Advanced Patterns

Environment-specific Validation

const envSchema = z
  .object({
    NODE_ENV: z.enum(["development", "production", "test"]),
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().optional(),
  })
  .refine(
    (data) => {
      // Require Stripe in production
      if (data.NODE_ENV === "production" && !data.STRIPE_SECRET_KEY) {
        return false;
      }
      return true;
    },
    { message: "STRIPE_SECRET_KEY is required in production" }
  );
TypeScript

Transform Values

const envSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DEBUG: z
    .string()
    .transform((v) => v === "true")
    .default("false"),
  ALLOWED_ORIGINS: z
    .string()
    .transform((v) => v.split(","))
    .default(""),
});
TypeScript

Related patterns