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:
- Environment source — reads from
process.env(or yourruntimeEnvoverride) - Empty string handling — if
emptyStringAsUndefinedis enabled, empty strings becomeundefined - Prefix resolution — prepends the configured prefix to each key when reading the env
- Schema parsing — each variable is decoded through its Effect Schema
- Error collection — all validation errors are collected (not fail-fast) and thrown together
- 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:
| Bucket | Validated on server? | Validated on client? | Accessible on client? |
|---|---|---|---|
server | Yes | No | No — throws ClientAccessError |
client | Yes | Yes | Yes |
shared | Yes | Yes | Yes |
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
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)
})
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,
},
})
The onValidationError callback fires before the EnvValidationError is thrown. The error is still thrown after the callback runs.