Free & open source — no account required
Free & open source — no account required
This technical guide provides an in-depth analysis of the env to typescript engine, best practices for implementation, and data security standards.
Every Node.js process accesses environment variables through process.env, typed as NodeJS.ProcessEnv — a Record<string, string | undefined> where every access returns string | undefined. This is technically correct but useless for type safety: a missing DATABASE_URL at runtime causes a connection error 30 seconds after startup, not at boot. The goal of env-to-TypeScript conversion is a validated, typed config object that either succeeds immediately at startup or throws an explicit error listing exactly which variables are missing — before any request is handled.
// Input .env
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
PORT=3000
NODE_ENV=production
JWT_SECRET=supersecretkey
LOG_LEVEL=info
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
ENABLE_EMAILS=true
MAX_UPLOAD_MB=10
// Generated TypeScript: src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
// Required: no default — startup fails if missing
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
// With defaults
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Coerced booleans (env vars are always strings)
ENABLE_EMAILS: z.string().transform(v => v === 'true').default('false'),
// Optional
MAX_UPLOAD_MB: z.coerce.number().positive().max(100).default(10),
SENTRY_DSN: z.string().url().optional(),
REDIS_URL: z.string().url().optional(),
});
// Parse at module load time — throws on startup if invalid
const _env = envSchema.safeParse(process.env);
if (!_env.success) {
const missing = _env.error.flatten().fieldErrors;
console.error('[Config Error] Invalid environment variables:');
Object.entries(missing).forEach(([key, errors]) => {
console.error(` ${key}: ${errors?.join(', ')}`);
});
process.exit(1); // fail fast before any server starts
}
export const env = _env.data;
export type Env = typeof env;
The critical pattern here is safeParse + process.exit(1) at module load time — not lazy validation inside a request handler. When the config module is first imported (before server.listen()), the check runs. A misconfigured deployment dies immediately with a useful error instead of starting up silently and failing on first use.
// Everywhere in the application — never access process.env directly
import { env } from '../config/env';
// Types are inferred from the Zod schema
env.PORT // number (coerced from string)
env.NODE_ENV // 'development' | 'test' | 'production'
env.ENABLE_EMAILS // boolean (transformed)
env.SENTRY_DSN // string | undefined (optional)
env.DATABASE_URL // string (guaranteed to be a valid URL)
// TypeScript prevents mistakes:
if (env.NODE_ENV === 'staging') { } // Error: 'staging' is not in the union type
const port: string = env.PORT; // Error: number is not assignable to string
// Database connection
import { drizzle } from 'drizzle-orm/node-postgres';
export const db = drizzle(env.DATABASE_URL); // never undefined
// JWT signing
import jwt from 'jsonwebtoken';
const token = jwt.sign(payload, env.JWT_SECRET);
// Install: npm install @t3-oss/env-nextjs zod
// src/env.ts (T3 Env for Next.js)
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
// Server-only variables (never sent to the browser)
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
EMAIL_API_KEY: z.string().optional(),
},
// Client-exposed variables (must start with NEXT_PUBLIC_)
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PK: z.string().startsWith('pk_'),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
},
// Map to actual process.env keys (required for client vars)
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
EMAIL_API_KEY: process.env.EMAIL_API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PK: process.env.NEXT_PUBLIC_STRIPE_PK,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
},
});
// T3 Env advantages:
// - Server-only keys throw if accessed in client code (tree-shaken in browser bundle)
// - NEXT_PUBLIC_ variables are automatically bundled into the client build
// - Validation runs at build time (next build) AND runtime
// types/env.d.ts — extend the global NodeJS.ProcessEnv type
// Useful for libraries that inject env vars at runtime (Vercel, Railway, etc.)
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'test' | 'production';
readonly DATABASE_URL: string;
readonly PORT: string; // still a string from process.env
readonly JWT_SECRET: string;
// Optional
readonly SENTRY_DSN?: string;
}
}
}
// This gives you intellisense on process.env.XXX
// But: still no runtime validation — use this only as a supplement
// The Zod approach above is always better for production reliability
// Node.js: load .env files per environment
// .env (default, committed with safe placeholders)
// .env.local (local overrides, gitignored)
// .env.test (test environment defaults, committed)
// package.json
{
"scripts": {
"dev": "dotenv -e .env.local node dist/server.js",
"test": "dotenv -e .env.test jest",
"start": "node dist/server.js" // production: real env vars injected by platform
}
}
// Programmatic loading (src/config/env.ts)
import 'dotenv/config'; // must be first import in the entry point
// OR
import dotenv from 'dotenv';
if (process.env.NODE_ENV !== 'production') {
dotenv.config({ path: '.env.local' });
}
// Rule: never commit .env.local or any file with real secrets
// The .env file in git should contain only comments and placeholder values:
// DATABASE_URL=postgresql://user:password@localhost:5432/dev_db
// Appropriate for env vars:
// - Database connection strings
// - API keys and secrets
// - JWT secrets
// - Service URLs that differ between environments
// - Feature flag values that change per environment
// NOT appropriate for env vars:
// - Application logic (use code or feature flags)
// - Large config objects (use a config file loaded by env var path)
// - Binary data (encode as base64 if needed)
// - Config that doesn't change between environments (use constants)
// Pattern for structured config from env vars:
const dbConfig = {
host: env.DB_HOST,
port: env.DB_PORT,
database: env.DB_NAME,
user: env.DB_USER,
password: env.DB_PASSWORD,
ssl: env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false,
};
process.env.VARIABLE! (non-null assertion): This tells TypeScript to trust you, not the runtime. Use Zod to actually validate the value exists — if the variable is missing in production, you get a clear error with the variable name, not a TypeError: Cannot read property 'split' of undefined deep in a library.z.coerce.number() for numeric env vars: All env vars are strings. z.number() rejects strings; z.coerce.number() converts "3000" to 3000. Similarly, use .transform(v => v === 'true') for booleans since "false" is truthy in JavaScript..optional() or .default(value)) self-document which features degrade gracefully vs. which break the app.Q: How do I validate env vars in a Vite/browser project?
A: Use import.meta.env instead of process.env, and Vite's built-in env schema validation via vite-plugin-checker or manually with Zod parsing import.meta.env in a vite.config.ts. All VITE_* prefixed variables are exposed to the browser; others are server-only.
Q: Should I commit .env files?
A: Only a .env.example file with placeholder values and comments. Real .env files with credentials must be gitignored. Use CI/CD platform secrets (GitHub Actions secrets, Vercel environment variables) to inject real values at deploy time.
Q: How do I handle env vars in Docker?
A: Pass them with --env-file .env or -e KEY=VALUE at container start. In Docker Compose: env_file: .env in the service config. Never bake secrets into a Docker image with ARG or ENV in a Dockerfile — they are visible in layer history.
Q: What's the difference between dotenv and runtime env vars?
A: dotenv loads a .env file into process.env before your app runs — it's for local development. In production, env vars should be injected directly by the platform (Heroku config vars, AWS parameter store, Kubernetes secrets) so no file containing secrets lives on the server's filesystem.
Is the processing local-only?
Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.
Can I use this for enterprise projects?
Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.
Why pasting proprietary company data into third-party web tools is a major liability, and how to stay safe.
A deep dive into combining Zod, React Query, and TypeScript for bulletproof API integration.
Code generation is just the beginning. Discover how a schema-first approach can eliminate 90% of your integration bugs.