Validating Configuration in TypeScript
Runtime configuration validation ensures required values exist and are correctly typed.
Basic Validation Pattern
interface AppConfig {
youtube: {
enabled: boolean;
clientId: string;
clientSecret: string;
};
api: {
timeout: number;
maxRetries: number;
};
}
function validateConfig(config: unknown): AppConfig {
if (!config || typeof config !== 'object') {
throw new Error('Invalid config: not an object');
}
const c = config as Partial<AppConfig>;
// Validate youtube section
if (!c.youtube || typeof c.youtube !== 'object') {
throw new Error('Invalid config: missing youtube section');
}
if (typeof c.youtube.enabled !== 'boolean') {
throw new Error('Invalid config: youtube.enabled must be boolean');
}
if (c.youtube.enabled) {
if (!c.youtube.clientId || typeof c.youtube.clientId !== 'string') {
throw new Error('Invalid config: youtube.clientId required when enabled');
}
if (!c.youtube.clientSecret || typeof c.youtube.clientSecret !== 'string') {
throw new Error('Invalid config: youtube.clientSecret required when enabled');
}
}
// Validate api section
if (!c.api || typeof c.api !== 'object') {
throw new Error('Invalid config: missing api section');
}
if (typeof c.api.timeout !== 'number' || c.api.timeout <= 0) {
throw new Error('Invalid config: api.timeout must be positive number');
}
return config as AppConfig;
}
With Default Values
interface ConfigWithDefaults {
api: {
timeout: number;
maxRetries: number;
};
}
function loadConfigWithDefaults(userConfig: Partial<ConfigWithDefaults>): ConfigWithDefaults {
const defaults: ConfigWithDefaults = {
api: {
timeout: 30000,
maxRetries: 3,
},
};
return {
api: {
timeout: userConfig.api?.timeout ?? defaults.api.timeout,
maxRetries: userConfig.api?.maxRetries ?? defaults.api.maxRetries,
},
};
}
Using Zod for Schema Validation
import { z } from 'zod';
const ConfigSchema = z.object({
youtube: z.object({
enabled: z.boolean(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
port: z.number().int().min(1024).max(65535).default(9671),
}),
api: z.object({
timeout: z.number().positive().default(30000),
maxRetries: z.number().int().min(0).max(10).default(3),
}),
features: z.object({
useProImageModel: z.boolean().default(false),
useProTextModel: z.boolean().default(false),
}).optional(),
});
type AppConfig = z.infer<typeof ConfigSchema>;
function loadAndValidateConfig(configPath: string): AppConfig {
const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
return ConfigSchema.parse(rawConfig);
}
Environment-Specific Configs
interface EnvironmentConfig {
NODE_ENV: 'development' | 'production' | 'test';
API_KEY: string;
PORT: number;
}
function validateEnv(): EnvironmentConfig {
const env = process.env;
if (!env.NODE_ENV || !['development', 'production', 'test'].includes(env.NODE_ENV)) {
throw new Error('Invalid NODE_ENV');
}
if (!env.API_KEY) {
throw new Error('API_KEY is required');
}
return {
NODE_ENV: env.NODE_ENV as EnvironmentConfig['NODE_ENV'],
API_KEY: env.API_KEY,
PORT: parseInt(env.PORT || '3000', 10),
};
}
Configuration Validation Error
class ConfigValidationError extends Error {
constructor(
public readonly field: string,
public readonly expected: string,
public readonly received: unknown
) {
super(`Config error: ${field} expected ${expected}, got ${typeof received}`);
this.name = 'ConfigValidationError';
}
}
function requireField<T>(obj: Record<string, unknown>, field: string, type: string): T {
const value = obj[field];
if (value === undefined || value === null) {
throw new ConfigValidationError(field, type, 'undefined');
}
if (typeof value !== type) {
throw new ConfigValidationError(field, type, typeof value);
}
return value as T;
}
// Usage
const config = { timeout: '30000' };
const timeout = requireField<number>(config, 'timeout', 'number');
// Throws: ConfigValidationError: timeout expected number, got string
Config File Loader
import fs from 'fs';
import path from 'path';
class ConfigManager<T> {
private cachedConfig: T | null = null;
constructor(
private configPath: string,
private validator: (config: unknown) => T
) {}
load(): T {
if (this.cachedConfig) {
return this.cachedConfig;
}
if (!fs.existsSync(this.configPath)) {
throw new Error(`Config file not found: ${this.configPath}`);
}
const rawContent = fs.readFileSync(this.configPath, 'utf-8');
let rawConfig: unknown;
try {
rawConfig = JSON.parse(rawContent);
} catch (error) {
throw new Error(`Invalid JSON in config file: ${this.configPath}`);
}
try {
this.cachedConfig = this.validator(rawConfig);
return this.cachedConfig;
} catch (error) {
if (error instanceof ConfigValidationError) {
throw new Error(`Config validation failed: ${error.message}`);
}
throw error;
}
}
reload(): T {
this.cachedConfig = null;
return this.load();
}
}
Usage Example
// Define config schema
const configSchema = z.object({
youtube: z.object({
enabled: z.boolean(),
clientId: z.string(),
clientSecret: z.string(),
}),
});
type Config = z.infer<typeof configSchema>;
// Create manager
const configManager = new ConfigManager(
'config.json',
(config) => configSchema.parse(config)
);
// Load and validate
const config = configManager.load();
// Use config
if (config.youtube.enabled) {
console.log(`Client ID: ${config.youtube.clientId}`);
}
Best Practices
- Validate at startup: Fail fast with clear error messages
- Provide defaults: For optional configuration
- Type the config: Use TypeScript interfaces or Zod schemas
- Document fields: Use JSDoc or Zod descriptions
- Environment-specific: Different configs for dev/prod
- Secrets separate: Use environment variables for sensitive data
- Validation errors: Include field name and expected type