TypeScript Application Configuration: From Simple to Complex
Any modern application requires flexible and secure configuration. Whether it's API keys, database connection parameters, server ports, or feature flags — configuration should be separated from code, easy to read, and validated at startup. In the world of TypeScript, this task has its own specifics: configuration typing provides autocompletion and prevents runtime errors. In this article, we will cover best practices, popular libraries, and tools for managing TypeScript application configuration.
Why not use process.env directly?
At first glance, reading environment variables via process.env seems like a simple solution. However, this approach has significant drawbacks:
- Lack of typing: all values are strings, even if logically they are numbers or booleans.
- Risk of typos: it's easy to write
process.env.DB_HOTSinstead ofDB_HOSTand getundefined. - No validation: if a required variable is not set, the application will fail at the most unexpected moment, not at startup.
Proper configuration solves all these problems.
The dotenv library and its TypeScript wrappers
The de facto standard for loading .env files is the dotenv library. For TypeScript, it's convenient to use dotenv-flow or dotenv-expand, which support multiple environments (development, staging, production).
// Installation// npm install dotenv dotenv-expand
// config/env.tsimport dotenv from 'dotenv';import dotenvExpand from 'dotenv-expand';
const myEnv = dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}` });dotenvExpand.expand(myEnv);After calling this function, all variables from the .env file will be available via process.env, but we still don't have types.
Schema validation with Zod
Zod is a powerful library for declarative schema validation with first-class TypeScript support. It is ideal for configuration:
// config/schema.tsimport { z } from 'zod';
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().positive().default(3000), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), REDIS_HOST: z.string().default('localhost'), REDIS_PORT: z.coerce.number().default(6379), FEATURE_NEW_UI: z .string() .transform((val) => val === 'true') .default('false'),});
export type EnvConfig = z.infer<typeof envSchema>;Now we can safely parse and validate the environment at startup:
// config/index.tsimport { envSchema, EnvConfig } from './schema';
let config: EnvConfig;
export function loadConfig(): EnvConfig { if (config) return config; // caching
const result = envSchema.safeParse(process.env); if (!result.success) { console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors); process.exit(1); } config = result.data; return config;}
export function getConfig(): EnvConfig { if (!config) throw new Error('Config not loaded. Call loadConfig() first.'); return config;}The Conf library for managing application configuration
If your application requires not only reading but also writing configuration (e.g., user settings, cache), check out conf by Sindre Sorhus. It stores data in a JSON file and is fully typed.
// Installation// npm install conf
import Conf from 'conf';
interface AppConfig { lastOp