Published on

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

Authors

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.

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.coerce for type conversion (number, boolean)
  • Add onValidationError for 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 .env files

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.