@tmrp/env
v0.7.0
Published
Type-safe environment variable parsing, powered by Zod.
Maintainers
Readme
@tmrp/env
Type-safe environment variables for TypeScript — validated with Zod, everywhere your code runs.
@tmrp/env reads environment variables from the current runtime or an explicit
env-like object, validates them with Zod schemas, and returns a strongly typed
object for application code. It supports Node.js, Deno, Bun, Cloudflare
Workers, Vercel Edge, Netlify, browser-injected config, and
import.meta.env-based toolchains.
import { createEnv } from "@tmrp/env";
import z from "zod";
export const env = createEnv({
API_URL: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().positive(),
});
env.API_URL; // string ✅ fully typed
env.PORT; // number ✅ validated at startupContents
- Features
- Installation
- Quick Start
- Runtime Entry Points
- Runtime Usage
- Defining Schemas
- Required Values
- Skipping Validation
- Error Behavior
- Example
.env - API Reference
- Development
- Project Structure
- Notes And Limitations
- License
Features
- ✅ Validate environment variables with any Zod schema.
- 🧠 Infer TypeScript types directly from your schema definitions.
- 💥 Fail fast when required environment variables are missing.
- 📋 Optionally report all schema validation issues together with structured errors.
- 🏗️ Skip validation for build or CI steps where runtime env vars are unavailable.
- 🌍 Support Node.js
process.env, DenoDeno.env.get, BunBun.env, and known global env records. - 🔌 Validate explicit env records for edge runtimes, serverless bindings,
browser config, and
import.meta.env. - 🎯 Use runtime-specific entry points when you know the target runtime.
Installation
pnpm add @tmrp/env zodzod is used directly when defining schemas. This package also depends on
effect internally.
Quick Start
import { createEnv } from "@tmrp/env";
import z from "zod";
export const env = createEnv({
API_URL: z.url(),
DATABASE_URL: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().positive(),
});
// Fully typed:
env.API_URL; // string
env.PORT; // numberIf a value does not match its schema, createEnv throws during initialization.
Required variables therefore fail fast when missing, while optional and
defaulted schemas retain their normal Zod behavior.
Runtime Entry Points
| Entry point | Export | Runtime behavior |
| ----------------------- | --------------------- | --------------------------------------------------------------------------------- |
| @tmrp/env | createEnv | Detects supported runtime globals and reads from the available runtime. |
| @tmrp/env/bun | createBunEnv | Reads from Bun Bun.env. |
| @tmrp/env/node | createNodeEnv | Reads from Node.js process.env. |
| @tmrp/env/deno | createDenoEnv | Reads from Deno Deno.env.get. |
| @tmrp/env/record | createRecordEnv | Reads from an explicit object. |
| @tmrp/env/cloudflare | createCloudflareEnv | Reads from Cloudflare Worker bindings passed to a handler. |
| @tmrp/env/vercel-edge | createVercelEdgeEnv | Reads from an explicit env object in Vercel Edge code. |
| @tmrp/env/netlify | createNetlifyEnv | Reads from an explicit Netlify env object. |
| @tmrp/env/browser | createBrowserEnv | Reads from an explicit browser config object. |
| @tmrp/env/import-meta | createImportMetaEnv | Reads from import.meta.env for Vite, Astro, SvelteKit, Nuxt, and similar tools. |
Runtime Usage
Default Runtime Detection
Use createEnv when you want to read from whichever supported runtime globals
are available. It checks Bun, Vercel Edge, Netlify, Node, Deno, Cloudflare
global bindings, import.meta.env global shims, and browser config globals.
import { createEnv } from "@tmrp/env";
import z from "zod";
const env = createEnv({
SERVICE_NAME: z.string().min(1),
});For runtimes that do not expose env values through standard globals, prefer the explicit record entry points. The default reader also supports these optional global records when your application intentionally exposes them:
| Global | Used by |
| -------------------------------- | -------------------------------------------------- |
| globalThis.__CLOUDFLARE_ENV__ | Cloudflare-style bindings |
| globalThis.__IMPORT_META_ENV__ | import.meta.env values copied to a global record |
| globalThis.__APP_CONFIG__ | Browser application config |
| globalThis.__ENV__ | Browser application config fallback |
Bun
Use createBunEnv when your application runs in Bun.
import { createBunEnv } from "@tmrp/env/bun";
import z from "zod";
const env = createBunEnv({
DATABASE_URL: z.url(),
PORT: z.coerce.number().int().positive(),
});Values are read from Bun.env.
Node.js
Use createNodeEnv when your application runs in Node and you want the runtime
expectation to be explicit.
import { createNodeEnv } from "@tmrp/env/node";
import z from "zod";
const env = createNodeEnv({
DATABASE_URL: z.url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]),
});Node values are read from process.env. If you use a local .env file, load it
before creating the environment object:
import "dotenv/config";
import { createNodeEnv } from "@tmrp/env/node";
import z from "zod";
const env = createNodeEnv({
TEST_ENV: z.string(),
});Deno
Use createDenoEnv when your application runs in Deno.
import { createDenoEnv } from "@tmrp/env/deno";
import z from "zod";
const env = createDenoEnv({
API_TOKEN: z.string().min(1),
});Deno environment access requires the appropriate permission, such as
--allow-env:
deno run --allow-env src/main.tsRecords And Passed Bindings
Use createRecordEnv for env-like objects that are passed to your code instead
of exposed globally.
import { createRecordEnv } from "@tmrp/env/record";
import z from "zod";
const env = createRecordEnv(
{
API_URL: z.url(),
FEATURE_ENABLED: z.coerce.boolean(),
},
runtimeEnv
);Values are passed to Zod as-is: string values keep their surrounding whitespace, and non-string values are passed directly, which is useful for framework-provided booleans or platform bindings.
Cloudflare Workers
Cloudflare Workers pass bindings into handlers instead of exposing them on
globalThis.
import { createCloudflareEnv } from "@tmrp/env/cloudflare";
import z from "zod";
export default {
fetch(request: Request, bindings: Record<string, unknown>) {
const env = createCloudflareEnv(
{
API_URL: z.url(),
API_TOKEN: z.string().min(1),
},
bindings
);
return fetch(env.API_URL, {
headers: { Authorization: `Bearer ${env.API_TOKEN}` },
});
},
};Vercel Edge
Use createVercelEdgeEnv with the env object available to your edge code.
import { createVercelEdgeEnv } from "@tmrp/env/vercel-edge";
import z from "zod";
const env = createVercelEdgeEnv(
{
API_URL: z.url(),
},
process.env
);Netlify
Use createNetlifyEnv with the env object available to your Netlify function or
edge function.
import { createNetlifyEnv } from "@tmrp/env/netlify";
import z from "zod";
const env = createNetlifyEnv(
{
API_URL: z.url(),
},
process.env
);Browser Config
Use createBrowserEnv for explicit browser configuration objects. This avoids
reading secrets from a client bundle by accident; only pass values that are safe
to expose publicly.
import { createBrowserEnv } from "@tmrp/env/browser";
import z from "zod";
const env = createBrowserEnv(
{
PUBLIC_API_URL: z.url(),
},
globalThis.__APP_CONFIG__ as Record<string, unknown>
);import.meta.env
Use createImportMetaEnv for Vite, Astro, SvelteKit, Nuxt, and other tools that
expose environment values through import.meta.env.
import { createImportMetaEnv } from "@tmrp/env/import-meta";
import z from "zod";
const env = createImportMetaEnv(
{
VITE_API_URL: z.url(),
DEV: z.boolean(),
},
import.meta.env
);Client-exposed Variables (clientPrefix)
When building applications that run code on both the server and client-side
(e.g. Next.js, Vite, Nuxt), the bundler statically injects environment variables
that are prefixed with a specific keyword (like NEXT_PUBLIC_ or VITE_).
On the client-side, trying to validate server-only environment variables will
normally throw errors because those variables are not exposed. To prevent this,
you can pass the clientPrefix option:
import { createEnv } from "@tmrp/env";
import z from "zod";
const env = createEnv(
{
DATABASE_URL: z.string(), // Server-only
NEXT_PUBLIC_API_URL: z.url(), // Client-exposed
},
{
clientPrefix: "NEXT_PUBLIC_",
}
);When running in a client environment (determined automatically by checking if
"window" in globalThis is false, or overridden by passing isServer: false),
only keys that start with the clientPrefix will be read and validated. All
other keys will bypass validation and return undefined on the client.
Defining Schemas
Pass an object whose keys are environment variable names and whose values are Zod schemas.
const env = createEnv({
FEATURE_ENABLED: z.coerce.boolean(),
MAX_RETRIES: z.coerce.number().int().nonnegative(),
PUBLIC_URL: z.url(),
SECRET_KEY: z.string().min(32),
});The returned object is inferred from the schema object:
env.FEATURE_ENABLED; // boolean
env.MAX_RETRIES; // number
env.PUBLIC_URL; // string
env.SECRET_KEY; // stringRuntime global values are usually strings. Use Zod coercion or transforms when you need booleans, numbers, dates, JSON parsing, or custom formats. Explicit record-based entry points can also pass non-string values directly to Zod.
Required Values
Missing variables are passed to Zod as undefined, so the schema determines
whether each value is required, optional, or defaulted.
const env = createEnv({
REQUIRED_TOKEN: z.string().min(1),
OPTIONAL_TOKEN: z.string().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});If REQUIRED_TOKEN is missing, creation fails immediately. The other two
variables can be omitted: OPTIONAL_TOKEN becomes undefined and LOG_LEVEL
becomes "info".
Explicit record-based runtimes also pass null through to Zod, allowing schemas
such as z.string().nullable() to handle it.
Skipping Validation
Use skipValidation when code needs to import or initialize an env module in a
context where runtime environment variables are not available, such as CI,
documentation builds, static analysis, or framework build steps.
import { createEnv } from "@tmrp/env";
import z from "zod";
export const env = createEnv(
{
API_URL: z.url(),
DATABASE_URL: z.url(),
PORT: z.coerce.number().int().positive(),
},
{ skipValidation: process.env.CI === "true" }
);When skipValidation is enabled, missing variables are returned as undefined
instead of throwing, and existing values are returned raw. Zod parsing,
validation, coercion, transforms, and defaults are not applied. Because those
raw values have not been validated, their properties are typed as unknown.
const env = createEnv(
{
PORT: z.coerce.number(),
SECRET_KEY: z.string().min(32),
},
{ skipValidation: true }
);
env.PORT; // raw string value, or undefined when unavailable
env.SECRET_KEY; // raw string value, or undefined when unavailableThis option is intended for non-runtime code paths. Do not use it for application startup if the returned values will be used as validated configuration.
Error Behavior
All creators run synchronously and throw on failure. By default (or with
errorMode: "first"), validation stops at the first failing variable.
Missing required variable error:
Environment variable "DATABASE_URL" is not definedValidation error:
Environment variable "PORT" failed validation: ...Configuration that does not satisfy its schema fails application startup.
Missing optional or defaulted variables are valid, according to their schemas.
When skipValidation is enabled, missing and invalid values do not throw.
Collecting All Validation Errors
Pass errorMode: "all" to any creator to collect schema validation issues across
all configured variables and throw one EnvValidationError:
import { EnvValidationError } from "@tmrp/env";
import { createRecordEnv } from "@tmrp/env/record";
import z from "zod";
try {
createRecordEnv(
{
DATABASE_URL: z.url({ error: "Required URL" }),
PORT: z.coerce.number().positive({ error: "Expected a positive number" }),
},
{ PORT: "-1" },
{ errorMode: "all" }
);
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(error.message);
// Each issue has key, code, path, and message fields:
// error.issues[0].key === "DATABASE_URL"
} else {
throw error;
}
}Invalid environment configuration:
DATABASE_URL — Required URL
PORT — Expected a positive numberIssues follow schema-key order and retain Zod's issue order within each variable.
key is the environment variable name; path is the path within its value (for
example, ["ports", 0] for a nested record binding). The message includes this
path as SETTINGS.ports.0. The EnvValidationIssue type is also exported from
@tmrp/env.
Generated issues contain only key, code, path, and message; raw input
values and the original Zod error are not attached. Zod messages and paths are
preserved, so avoid embedding secrets in custom error messages or object keys.
Optional values, defaults, coercion, and transforms retain their normal behavior.
Client-prefix filtering happens before reading or validating a variable, and
skipValidation: true bypasses parsing and aggregation. Read failures and
exceptions thrown by schema callbacks still stop creation immediately using the
existing error behavior; they are not collected as validation issues.
Example .env
TEST_ENV="this-is-a-test-value"
DATABASE_URL="https://example.com/database"
PORT="3000"
NODE_ENV="development"An example file is included at .env.example.
API Reference
createEnv(envKeys, options?)
Reads variables from the detected supported runtime and validates them.
type Options = {
clientPrefix?: string;
errorMode?: "all" | "first";
isServer?: boolean;
skipValidation?: boolean;
};
function createEnv<
const TEnvKeys extends Record<string, ZodType>,
const TOptions extends Options | undefined = undefined,
>(envKeys: TEnvKeys, options?: TOptions): EnvForOptions<TEnvKeys, TOptions>;EnvForOptions returns parsed Zod outputs during normal validation, unknown
raw values when validation can be skipped, and includes undefined for keys
that client-prefix filtering can remove.
Use this from @tmrp/env.
createBunEnv(envKeys, options?)
Reads variables from Bun Bun.env and validates them.
function createBunEnv<
const TEnvKeys extends Record<string, ZodType>,
const TOptions extends Options | undefined = undefined,
>(envKeys: TEnvKeys, options?: TOptions): EnvForOptions<TEnvKeys, TOptions>;Use this from @tmrp/env/bun.
createNodeEnv(envKeys, options?)
Reads variables from Node.js process.env and validates them.
function createNodeEnv<
const TEnvKeys extends Record<string, ZodType>,
const TOptions extends Options | undefined = undefined,
>(envKeys: TEnvKeys, options?: TOptions): EnvForOptions<TEnvKeys, TOptions>;Use this from @tmrp/env/node.
createDenoEnv(envKeys, options?)
Reads variables from Deno Deno.env.get and validates them.
function createDenoEnv<
const TEnvKeys extends Record<string, ZodType>,
const TOptions extends Options | undefined = undefined,
>(envKeys: TEnvKeys, options?: TOptions): EnvForOptions<TEnvKeys, TOptions>;Use this from @tmrp/env/deno.
createRecordEnv(envKeys, record, options?)
Reads variables from an explicit object and validates them.
function createRecordEnv<
const TEnvKeys extends Record<string, ZodType>,
const TOptions extends Options | undefined = undefined,
>(
envKeys: TEnvKeys,
record: Record<string, unknown>,
options?: TOptions
): EnvForOptions<TEnvKeys, TOptions>;Use this from @tmrp/env/record.
Platform Record Creators
These entry points all use the same explicit-record validation model, but expose runtime-specific names for clearer application code:
| Entry point | Function |
| ----------------------- | ------------------------------------------------------- |
| @tmrp/env/cloudflare | createCloudflareEnv(envKeys, bindings, options?) |
| @tmrp/env/vercel-edge | createVercelEdgeEnv(envKeys, env, options?) |
| @tmrp/env/netlify | createNetlifyEnv(envKeys, env, options?) |
| @tmrp/env/browser | createBrowserEnv(envKeys, env, options?) |
| @tmrp/env/import-meta | createImportMetaEnv(envKeys, importMetaEnv, options?) |
All creators accept the same Options object. Set errorMode: "all" to collect
schema validation issues in an EnvValidationError. Set skipValidation: true
to return raw values and undefined for unavailable variables instead of throwing.
Development
This repository uses pnpm and TypeScript.
Requirements
- Node.js 24.x
- pnpm
The repository includes .nvmrc and .npmrc files for local Node version
alignment.
Install Dependencies
pnpm installTypecheck
pnpm typecheckLint
pnpm lintTest
pnpm test
pnpm test:coverageCoverage thresholds are 100% for statements, branches, functions, and lines.
Build
pnpm buildReleasing
This package uses Changesets.
Versioning and npm publishing are automated — include a changeset with your PR
(pnpm changeset) and the release flow handles the rest. See
CONTRIBUTING.md for the full workflow.
Project Structure
src/
create-env.ts Default runtime-aware environment creator
index.ts Default public export
effects/ Internal Effect-based read and parse pipeline
lib/ Shared schemas, types, and runtime reader
runtime/
browser/ Browser config entry point
bun/ Bun-specific entry point and reader
cloudflare/ Cloudflare bindings entry point
deno/ Deno-specific entry point and reader
import-meta/ import.meta.env entry point
netlify/ Netlify env object entry point
node/ Node-specific entry point and reader
record/ Generic record/object entry point
vercel/ Vercel Edge env object entry pointNotes And Limitations
- String values are passed to Zod as-is; surrounding whitespace is not trimmed.
- Reads are synchronous.
- The package validates configuration at creation time, not lazily.
- Runtime global values are usually strings; explicit records can also pass non-string values to Zod.
- Missing environment variables are passed to Zod as
undefined, allowing optional and defaulted schemas to handle them. - Browser and build-tool entry points validate values you explicitly pass to them. The default reader can also use the documented global records when your application intentionally exposes them.
