better-env better-env Docs

Core Concepts

Understand how better-env validates variables, separates client/server access, and uses a proxy for runtime safety.

Validation flow

When you call createEnv(), the following happens:

  1. Environment source — reads from process.env (or your runtimeEnv override)
  2. Empty string handling — if emptyStringAsUndefined is enabled, empty strings become undefined
  3. Prefix resolution — prepends the configured prefix to each key when reading the env
  4. Schema parsing — each variable is decoded through its Effect Schema
  5. Error collection — all validation errors are collected (not fail-fast) and thrown together
  6. Proxy creation — the validated result is wrapped in a Proxy that enforces client/server access rules

If any variable fails validation, createEnv throws an EnvValidationError containing all failures at once, so you can fix them all in one pass.

Client/server separation

The server, client, and shared buckets control where variables can be accessed:

BucketValidated on server?Validated on client?Accessible on client?
serverYesNoNo — throws ClientAccessError
clientYesYesYes
sharedYesYesYes

How it works: The returned object is a Proxy. When running on the client (detected via typeof window !== "undefined"), accessing a key defined only in server throws a ClientAccessError.

const env = createEnv({
  server: {
    DATABASE_URL: requiredString,  // Only accessible on server
  },
  client: {
    NEXT_PUBLIC_API_URL: requiredString,  // Accessible everywhere
  },
})

// On the client:
env.NEXT_PUBLIC_API_URL // ✅ works
env.DATABASE_URL        // ❌ throws ClientAccessError
Warning

Server-only variables are not validated on the client to avoid requiring their values to be bundled. They are simply blocked from access.

Overriding server detection

By default, isServer is typeof window === "undefined". You can override this:

const env = createEnv({
  isServer: process.env.NEXT_RUNTIME !== "edge",
  // ...
})

Prefix handling

Prefixes map your schema keys to actual environment variable names. There are two formats:

String prefix

Applies the same prefix to all buckets:

createEnv({
  prefix: "MYAPP_",
  server: { DB_URL: requiredString },  // reads MYAPP_DB_URL from env
})

Prefix map

Different prefixes per bucket:

createEnv({
  prefix: {
    client: "NEXT_PUBLIC_",
    server: "",  // no prefix
  },
  server: { DB_URL: requiredString },           // reads DB_URL
  client: { APP_URL: requiredString },           // reads NEXT_PUBLIC_APP_URL
})

This is what framework presets configure for you.

Redacted values

When you use Schema.Redacted(Schema.String) (or the redacted helper), the value is wrapped in Effect’s Redacted type during validation. This means the value is safe to log, serialize, and spread — secrets never leak accidentally.

import { Redacted } from "effect"

const env = createEnv({
  server: {
    API_SECRET: redacted(Schema.String),
  },
})

// env.API_SECRET is Redacted<string> — safe to log, serialize, spread
console.log(env.API_SECRET)     // <redacted>
JSON.stringify(env)              // {"API_SECRET":"<redacted>"}

// Explicitly unwrap when you need the plain value
const secret: string = Redacted.value(env.API_SECRET)

Empty string handling

Some hosting providers set environment variables to empty strings instead of leaving them undefined. Enable emptyStringAsUndefined to treat them as missing:

createEnv({
  emptyStringAsUndefined: true,
  server: {
    OPTIONAL_VAR: Schema.optional(Schema.String),
  },
})

Runtime env override

By default, createEnv reads from process.env. You can provide a custom source:

createEnv({
  runtimeEnv: {
    DATABASE_URL: "postgresql://...",
    PORT: "3000",
  },
  server: {
    DATABASE_URL: requiredString,
    PORT: port,
  },
})

This is useful for testing or for frameworks like Vite where import.meta.env is the source.

Testing

Combine runtimeEnv with isServer to write deterministic tests without touching real environment variables:

import { createEnv, requiredString, postgresUrl, port, withDefault } from "@ayronforge/better-env"
import { expect, test } from "vitest"

test("env parses correctly", () => {
  const env = createEnv({
    server: {
      DATABASE_URL: postgresUrl,
      PORT: withDefault(port, 3000),
    },
    runtimeEnv: {
      DATABASE_URL: "postgresql://user:pass@localhost:5432/testdb",
    },
    isServer: true, // Force server mode so server vars are validated
  })

  expect(env.DATABASE_URL).toBe("postgresql://user:pass@localhost:5432/testdb")
  expect(env.PORT).toBe(3000)
})
Tip

Setting isServer: true ensures server-only variables are validated and accessible, even if your test runner runs in an environment where typeof window !== "undefined" (e.g. jsdom).

Validation error callback

You can hook into validation errors before the exception is thrown:

createEnv({
  onValidationError: (errors) => {
    // Log to your error tracking service
    Sentry.captureMessage("Env validation failed", { extra: { errors } })
  },
  server: {
    DATABASE_URL: requiredString,
  },
})
Note

The onValidationError callback fires before the EnvValidationError is thrown. The error is still thrown after the callback runs.