- Published on
Type-Safe Environment Variables in 2026 — T3 Env, Zod, and Runtime Validation
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
process.env.PORT is typed as string | undefined. This runtime uncertainty causes bugs. T3 Env and Zod enable type-safe environment variables that are validated at startup, preventing crashes in production. Essential for all backend applications.
Why process.env.X is Dangerous
The standard approach fails silently:
const port = process.env.PORT; // Type: string | undefined
// Bug: port could be undefined
const server = app.listen(port); // ✗ Runtime error
// Bug: parseInt silently returns NaN
const portNum = parseInt(port); // ✗ NaN if PORT not set
// Bug: forgot to parse, string sent to db connection
const dbUrl = `postgres://localhost:${port}/db`; // ✗ Wrong URL
Apps crash in production because environment variables aren't validated at startup. By the time an error surfaces, users are impacted.
T3 Env Library Overview
T3 Env validates environment variables at startup and provides typed access:
npm install @t3-oss/env-core zod
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(['development', 'production']).default('development'),
SECRET: z.string().min(16),
API_KEY: z.string().optional(),
},
runtimeEnv: process.env,
});
// Typed, validated, safe
const port = env.PORT; // Type: number, never undefined
const dbUrl = env.DATABASE_URL; // Type: string
const secret = env.SECRET; // Type: string
const apiKey = env.API_KEY; // Type: string | undefined
If any required variable is missing, T3 Env throws an error at startup:
Error: Invalid environment variables:
DATABASE_URL: Required
SECRET: Required
The application never starts without proper configuration.
Defining Server and Client Env Schemas
T3 Env separates server-only variables from client-accessible ones:
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
// Server-only: never leaked to browser
DATABASE_URL: z.string().url(),
STRIPE_SECRET: z.string(),
GITHUB_TOKEN: z.string(),
},
client: {
// Safe for browser: visible in bundle
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET: process.env.STRIPE_SECRET,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
},
});
// TypeScript prevents accessing server vars from client code
export const publicEnv = {
apiUrl: env.NEXT_PUBLIC_API_URL,
stripeKey: env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
};
// This throws a compile error if imported in browser context
export const secretEnv = {
dbUrl: env.DATABASE_URL,
stripeSecret: env.STRIPE_SECRET,
};
Combining @t3-oss/env-nextjs with @t3-oss/env-core for full-stack type safety.
onValidationError for Friendly Startup Failures
Custom error handling when validation fails:
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number(),
},
runtimeEnv: process.env,
onValidationError: (error) => {
console.error('❌ Invalid environment variables:');
error.errors.forEach((err) => {
console.error(` ${err.path.join('.')}: ${err.message}`);
});
process.exit(1);
},
onInvalidAccess: (variable) => {
throw new Error(`Attempted to access environment variable: ${variable}`);
},
});
Output:
❌ Invalid environment variables:
DATABASE_URL: Invalid url
PORT: Expected number, received nan
Developers see exactly what's wrong without debugging cryptic runtime errors.
- Why process.env.X is Dangerous
- T3 Env Library Overview
- Defining Server and Client Env Schemas
- onValidationError for Friendly Startup Failures
- Zod v4 for Env Validation
- dotenv-vault for Encrypted .env Files
- Infisical and Doppler SDK Integration
- Testing with Mock Env
- Vercel, Railway, and Fly.io Env Management
- Env Schema as Documentation
- Checklist
- Conclusion
Zod v4 for Env Validation
Zod v4 provides powerful schema options for environment variables:
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const env = createEnv({
server: {
// Type coercion: string to number/boolean
PORT: z.coerce.number().positive().max(65535).default(3000),
DEBUG: z.enum(['true', 'false']).transform(v => v === 'true').default('false'),
// URLs must be valid
DATABASE_URL: z.string().url().includes('postgres'),
// Enums for required choices
ENVIRONMENT: z.enum(['dev', 'staging', 'prod']),
// Email validation
ADMIN_EMAIL: z.string().email(),
// Optional with default
CACHE_TTL: z.coerce.number().default(3600),
// Pipe for complex transformations
LOG_LEVEL: z.pipe(
z.string(),
z.string().toLowerCase(),
z.enum(['debug', 'info', 'warn', 'error'])
),
// Array: comma-separated values
ALLOWED_HOSTS: z.string().transform((v) => v.split(',')),
// JSON parsing
FEATURE_FLAGS: z.string().transform((v) => JSON.parse(v)).default('{}'),
},
runtimeEnv: process.env,
});
// All types are correctly inferred
const port: number = env.PORT;
const debug: boolean = env.DEBUG;
const hosts: string[] = env.ALLOWED_HOSTS;
const flags: Record<string, boolean> = env.FEATURE_FLAGS;
dotenv-vault for Encrypted .env Files
Secure .env files in version control with dotenv-vault:
npm install dotenv-vault
npx dotenv-vault new
Encrypt sensitive values:
npx dotenv-vault set SECRET "super-secret-value"
npx dotenv-vault push
.env.vault (safe to commit):
DOTENV_KEY="dotenv://:key_5e12d586412906ff5ba3@dotenv.org/vault/.env.vault?environment=production"
.env.production.local (git-ignored, decrypted at runtime):
DATABASE_URL=postgres://prod.db:5432/app
SECRET=super-secret-value
T3 Env + dotenv-vault in production:
import 'dotenv-vault/config'; // Decrypt .env.production.local
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
SECRET: z.string().min(16),
},
runtimeEnv: process.env,
});
Infisical and Doppler SDK Integration
Modern secret management platforms provide SDKs:
Infisical:
import { InfisicalClient } from '@infisical/sdk';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const infisical = new InfisicalClient();
const secrets = await infisical.listSecrets({
environment: 'prod',
projectId: process.env.INFISICAL_PROJECT_ID,
token: process.env.INFISICAL_TOKEN,
});
const secretsObj = secrets.reduce((acc, secret) => {
acc[secret.secretKey] = secret.secretValue;
return acc;
}, {} as Record<string, string>);
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
API_KEY: z.string(),
},
runtimeEnv: secretsObj,
});
Doppler:
import { Doppler } from '@doppler/sdk';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const doppler = new Doppler({
token: process.env.DOPPLER_TOKEN,
});
const secrets = await doppler.secrets.list({
project: 'my-project',
config: 'prod',
});
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
API_KEY: z.string(),
},
runtimeEnv: secrets.body.secrets,
});
Both integrate seamlessly with T3 Env for validated, typed secrets.
Testing with Mock Env
Mock environment variables in tests:
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
import test from 'node:test';
import assert from 'node:assert';
const createTestEnv = (overrides: Record<string, string>) =>
createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
},
runtimeEnv: {
DATABASE_URL: 'postgres://localhost:5432/test_db',
PORT: '3000',
...overrides,
},
});
test('env with custom port', () => {
const env = createTestEnv({ PORT: '8080' });
assert.strictEqual(env.PORT, 8080);
});
test('env validation fails with invalid url', () => {
assert.throws(() => {
createTestEnv({ DATABASE_URL: 'invalid-url' });
});
});
Mock environment variables without modifying process.env:
// Better: use factory pattern
function createEnvForTest(overrides: Partial<NodeJS.ProcessEnv> = {}) {
const testEnv: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgres://localhost/test',
PORT: '3000',
...overrides,
};
return createEnv({
server: { /* ... */ },
runtimeEnv: testEnv,
});
}
Vercel, Railway, and Fly.io Env Management
Each platform provides environment variable UI and CLI tools.
Vercel:
# Set via CLI
vercel env add DATABASE_URL
# Set in Vercel dashboard
# Project Settings → Environment Variables
# Automatic injection
import { env } from '@/env';
Railway:
# Via Railway CLI
railway variables add DATABASE_URL postgres://...
# Or dashboard:
# Project → Variables tab
Fly.io:
# Via flyctl
fly secrets set DATABASE_URL=postgres://...
# View secrets
fly secrets list
All platforms inject variables into Node.js at runtime; use T3 Env to validate:
// Works identically on Vercel, Railway, Fly.io
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
},
runtimeEnv: process.env,
});
Env Schema as Documentation
Use T3 Env as living documentation of required variables:
// env.ts
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
/**
* Backend environment variables.
* Required for all deployments.
*/
export const env = createEnv({
server: {
/** PostgreSQL connection string for primary database */
DATABASE_URL: z.string().url(),
/** Redis URL for caching and sessions */
REDIS_URL: z.string().url(),
/** JWT secret for signing tokens (min 32 characters) */
JWT_SECRET: z.string().min(32),
/** Stripe API key for payment processing */
STRIPE_SECRET: z.string().startsWith('sk_'),
/** Deployment environment: dev, staging, or prod */
ENVIRONMENT: z.enum(['dev', 'staging', 'prod']),
/** HTTP server port (default: 3000) */
PORT: z.coerce.number().default(3000),
/** Log level for structured logging */
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
},
runtimeEnv: process.env,
});
README template using env schema:
## Environment Variables
See \`src/env.ts\` for complete list and validation rules.
### Required
- \`DATABASE_URL\`: PostgreSQL connection string
- \`JWT_SECRET\`: Min 32 characters for token signing
- \`STRIPE_SECRET\`: Stripe API key (sk_...)
### Optional
- \`PORT\`: HTTP port (default: 3000)
- \`LOG_LEVEL\`: debug, info, warn, error (default: info)
Checklist
- Install T3 Env and Zod
- Define all environment variables in schema
- Use
z.coercefor type conversion (number, boolean) - Add
onValidationErrorfor friendly failures - Test environment validation in unit tests
- Use dotenv-vault or Infisical/Doppler for secrets
- Document env schema in README
- Separate server and client variables (if using Next.js)
- Validate env at application startup (not lazy)
- Never commit actual
.envfiles
Conclusion
Type-safe environment variables prevent entire classes of bugs. T3 Env with Zod ensures that applications fail fast with clear error messages rather than crashing mysteriously in production. Combined with secret management platforms, environment validation is now a production essential for all Node.js backends.