| id |
config-layers-schema |
| title |
Composable Configuration Layers |
| category |
environment-config |
| skillLevel |
intermediate |
| tags |
environment |
config |
layers |
composition |
schema |
dependency-injection |
|
| lessonOrder |
5 |
| rule |
| description |
Composable Configuration Layers using Schema. |
|
| summary |
Real applications need configuration from multiple sources: default values, environment variables, config files, runtime overrides. Loading config becomes scattered across the codebase. Layers... |
Real applications need configuration from multiple sources: default values, environment variables, config files, runtime overrides. Loading config becomes scattered across the codebase. Layers conflict, precedence is unclear. You need a composable, layered configuration system where each layer overrides the previous one in a predictable order: defaults → files → env vars → runtime.
import { Schema, Effect, Record as R } from "effect"
import * as fs from "fs"
// 1. Define configuration schema
const DatabaseConfig = Schema.Struct({
host: Schema.String,
port: Schema.pipe(Schema.Number, Schema.int(), Schema.between(1024, 65535)),
database: Schema.String,
maxConnections: Schema.pipe(Schema.Number, Schema.int(), Schema.between(1, 1000)),
})
const CacheConfig = Schema.Struct({
ttl: Schema.pipe(Schema.Number, Schema.int(), Schema.between(0, 86400)),
enabled: Schema.Boolean,
})
const AppConfig = Schema.Struct({
database: DatabaseConfig,
cache: CacheConfig,
debug: Schema.Boolean,
})
type AppConfig = typeof AppConfig.Type
// 2. Define default configuration
const defaultConfig: AppConfig = {
database: {
host: "localhost",
port: 5432,
database: "app_db",
maxConnections: 10,
},
cache: {
ttl: 3600,
enabled: true,
},
debug: false,
}
// 3. Load from config file
const loadConfigFile = (path: string): Effect.Effect<Partial<AppConfig>, Error> =>
Effect.gen(function* () {
return yield* Effect.tryPromise({
try: async () => {
const content = await fs.promises.readFile(path, "utf-8")
return JSON.parse(content)
},
catch: (error) => {
const msg = error instanceof Error ? error.message : String(error)
return new Error(`Failed to load config file: ${msg}`)
},
})
})
// 4. Override with environment variables
const loadEnvOverrides = (): Partial<AppConfig> => {
const overrides: Partial<AppConfig> = {}
if (process.env.DB_HOST) {
overrides.database ??= {}
overrides.database.host = process.env.DB_HOST
}
if (process.env.DB_PORT) {
overrides.database ??= {}
overrides.database.port = parseInt(process.env.DB_PORT, 10)
}
if (process.env.CACHE_TTL) {
overrides.cache ??= {}
overrides.cache.ttl = parseInt(process.env.CACHE_TTL, 10)
}
if (process.env.DEBUG) {
overrides.debug = process.env.DEBUG === "true"
}
return overrides
}
// 5. Deep merge configuration layers
const mergeConfigs = (
base: AppConfig,
fileConfig: Partial<AppConfig>,
envConfig: Partial<AppConfig>,
): AppConfig => {
const merged = structuredClone(base)
// Merge file config
if (fileConfig.database) {
merged.database = { ...merged.database, ...fileConfig.database }
}
if (fileConfig.cache) {
merged.cache = { ...merged.cache, ...fileConfig.cache }
}
if (fileConfig.debug !== undefined) {
merged.debug = fileConfig.debug
}
// Merge env overrides (highest priority)
if (envConfig.database) {
merged.database = { ...merged.database, ...envConfig.database }
}
if (envConfig.cache) {
merged.cache = { ...merged.cache, ...envConfig.cache }
}
if (envConfig.debug !== undefined) {
merged.debug = envConfig.debug
}
return merged
}
// 6. Load and validate complete configuration
const loadConfig = (configFilePath?: string): Effect.Effect<AppConfig, Error> =>
Effect.gen(function* () {
// Load file config (if provided)
const fileConfig = configFilePath
? yield* loadConfigFile(configFilePath)
: {}
// Load env overrides
const envConfig = loadEnvOverrides()
// Merge all layers
const merged = mergeConfigs(defaultConfig, fileConfig, envConfig)
// Validate merged config
const validated = yield* Effect.tryPromise({
try: () => Schema.decodeUnknown(AppConfig)(merged),
catch: (error) => {
const msg = error instanceof Error ? error.message : String(error)
return new Error(`Config validation failed: ${msg}`)
},
})
// Log configuration layers
console.log("Configuration loaded:")
console.log(` Defaults: ✓`)
if (configFilePath) console.log(` File: ✓`)
if (Object.keys(envConfig).length > 0) console.log(` Env: ✓`)
return validated
})
// 7. Configuration service
class ConfigService {
constructor(readonly config: AppConfig) {}
getDatabase = () => this.config.database
getCache = () => this.config.cache
isDebug = () => this.config.debug
}
const ConfigServiceLive = (configPath?: string) =>
Effect.gen(function* () {
const config = yield* loadConfig(configPath)
return new ConfigService(config)
}).pipe(Effect.layer)
// Usage
const appLogic = Effect.gen(function* () {
const configService = yield* Effect.service(ConfigService)
const db = configService.getDatabase()
console.log(`Database: ${db.host}:${db.port}/${db.database}`)
console.log(`Max connections: ${db.maxConnections}`)
const cache = configService.getCache()
console.log(`Cache enabled: ${cache.enabled}, TTL: ${cache.ttl}s`)
console.log(`Debug mode: ${configService.isDebug()}`)
return configService.config
})
// Run with explicit config file path
Effect.runPromise(
appLogic.pipe(
Effect.provide(ConfigServiceLive("./config.json"))
)
)
.then((config) => console.log("App initialized successfully"))
.catch((error) => console.error(`Configuration error: ${error.message}`))
| Concept |
Explanation |
| Default values |
Safe fallbacks for all configuration |
| File config |
Persistent configuration without env vars |
| Environment overrides |
Production-specific values at runtime |
| Merge strategy |
Clear precedence: defaults → file → env |
| Schema validation |
All layers validated after merging |
| Service layer |
Configuration accessed via dependency injection |
| Immutable after load |
Config locked after validation, prevents mutations |
| Type safety |
TypeScript enforces config shape throughout app |
- Multi-environment deployments with different configs per environment
- Development (local config file) vs. production (env vars)
- Applications with both required and optional settings
- Twelve-factor app methodology implementations
- Microservices with environment-specific overrides
- When you need audit trail of config sources