# Configuration in Effect This guide shows you how to load and validate configuration in an Effect application. Two modules work together: - **`ConfigProvider`** — reads raw data from a source (environment variables, JSON objects, `.env` files, directory trees). - **`Config`** — describes what shape and types you expect, then decodes the raw data into typed values. You describe _what_ you need with `Config`, and the library figures out _how_ to read and validate it using a `ConfigProvider`. ## Getting Started ### Reading a Single Value The simplest case: read one value from an environment variable. ```ts import { Config, Effect } from "effect" const program = Effect.gen(function*() { const host = yield* Config.string("HOST") console.log(host) }) Effect.runSync(program) // reads HOST from process.env ``` When you yield a `Config` inside `Effect.gen`, it automatically uses the default `ConfigProvider` (which reads from `process.env`). ### Reading Multiple Values Use `Config.all` to group related keys: ```ts import { Config, ConfigProvider, Effect } from "effect" const dbConfig = Config.all({ host: Config.string("host"), port: Config.int("port") }) const provider = ConfigProvider.fromUnknown({ host: "localhost", port: 5432 }) const result = Effect.runSync(dbConfig.parse(provider)) // { host: "localhost", port: 5432 } ``` ### Reading Structured Config with a Schema For larger configs, use `Config.schema` with a `Schema.Struct`: ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" const AppConfig = Config.schema( Schema.Struct({ host: Schema.String, port: Schema.Int, debug: Schema.Boolean }) ) const provider = ConfigProvider.fromUnknown({ host: "localhost", port: 8080, debug: true }) const result = Effect.runSync(AppConfig.parse(provider)) // { host: "localhost", port: 8080, debug: true } ``` The schema automatically decodes raw string values into their target types. For example, when reading from environment variables, `"8080"` becomes the number `8080` and `"true"` becomes the boolean `true`. ## Config Constructors Each constructor reads a single value and decodes it into the appropriate type. | Constructor | Decoded type | Notes | | ------------------------------ | ------------------ | ------------------------------------------------------------------------ | | `Config.string(name?)` | `string` | Any string | | `Config.nonEmptyString(name?)` | `string` | Rejects `""` | | `Config.number(name?)` | `number` | Includes `NaN`, `Infinity` | | `Config.finite(name?)` | `number` | Rejects `NaN` and `Infinity` | | `Config.int(name?)` | `number` | Integers only | | `Config.boolean(name?)` | `boolean` | Accepts `true/false`, `yes/no`, `on/off`, `1/0`, `y/n` | | `Config.port(name?)` | `number` | Integer in 1–65535 | | `Config.url(name?)` | `URL` | Parsed via the `URL` constructor | | `Config.date(name?)` | `Date` | Rejects invalid dates | | `Config.duration(name?)` | `Duration` | Parses `"10 seconds"`, `"500 millis"`, `"Infinity"`, `"-Infinity"`, etc. | | `Config.logLevel(name?)` | `string` | One of `All`, `Fatal`, `Error`, `Warn`, `Info`, `Debug`, `Trace`, `None` | | `Config.redacted(name?)` | `Redacted` | Hidden from logs and `toString` | | `Config.literal(value, name?)` | literal type | Accepts only the given literal | The optional `name` parameter sets the local path segment for lookup. If the config is wrapped with `Config.nested`, the nested prefix is prepended to this local path. Omit `name` when the config should decode the provider root. ## Config Combinators ### `Config.withDefault` — Fallback for Missing Keys Only triggers when data is missing. Validation errors (wrong type, out of range) still propagate. ```ts import { Config, ConfigProvider, Effect } from "effect" const port = Config.int("port").pipe(Config.withDefault(3000)) const provider = ConfigProvider.fromUnknown({}) Effect.runSync(port.parse(provider)) // 3000 ``` ### `Config.option` — Optional Values Returns `Option.some(value)` on success and `Option.none()` when data is missing. ```ts import { Config, ConfigProvider, Effect } from "effect" const maybePort = Config.option(Config.int("port")) const provider = ConfigProvider.fromUnknown({}) Effect.runSync(maybePort.parse(provider)) // { _tag: "None" } ``` ### `Config.map` — Transform a Value ```ts import { Config } from "effect" const upperHost = Config.string("HOST").pipe( Config.map((s) => s.toUpperCase()) ) ``` ### `Config.orElse` — Fallback on Any Error Unlike `withDefault`, this catches **all** `ConfigError`s: ```ts import { Config } from "effect" const host = Config.string("HOST").pipe( Config.orElse(() => Config.succeed("localhost")) ) ``` ### `Config.nested` — Scope a Config Under a Prefix Prepends a logical path segment to every key the inner config reads. The prefix is used for both provider lookups and schema error paths: ```ts import { Config, ConfigProvider, Effect } from "effect" const dbConfig = Config.all({ host: Config.string("host"), port: Config.int("port") }).pipe(Config.nested("database")) const provider = ConfigProvider.fromUnknown({ database: { host: "localhost", port: 5432 } }) Effect.runSync(dbConfig.parse(provider)) // { host: "localhost", port: 5432 } ``` With environment variables, nesting uses `_` as separator: ```ts import { Config, ConfigProvider, Effect } from "effect" const host = Config.string("host").pipe(Config.nested("database")) const provider = ConfigProvider.fromEnv({ env: { database_host: "localhost" } }) Effect.runSync(host.parse(provider)) // "localhost" ``` Multiple `Config.nested` calls compose with the outermost prefix first: ```ts import { Config, ConfigProvider, Effect } from "effect" const config = Config.string("host").pipe( Config.nested("database"), Config.nested("production") ) const provider = ConfigProvider.fromUnknown({ production: { database: { host: "localhost" } } }) Effect.runSync(config.parse(provider)) // "localhost" ``` ### `Config.all` — Combine Multiple Configs Accepts a record or a tuple: ```ts import { Config } from "effect" // As a record const appConfig = Config.all({ host: Config.string("host"), port: Config.int("port"), debug: Config.boolean("debug") }) // As a tuple const pair = Config.all([Config.string("a"), Config.int("b")]) ``` ### Custom Config Logic There is no public low-level `Config.make` constructor. For custom validation or transformation, start from one of the public constructors or `Config.schema`, then use `Config.map`, `Config.mapOrFail`, `Config.all`, `Config.orElse`, or `Config.withDefault`. If you need custom lookup behavior for a new backing source, implement a `ConfigProvider` with `ConfigProvider.make` instead. ## Config Schemas For reusable codecs you can pass directly to `Config.schema`: | Schema | Type | Notes | | --------------------------- | -------------- | ------------------------------------------ | | `Config.Boolean` | `boolean` | Decodes `true/false/yes/no/on/off/1/0/y/n` | | `Schema.DurationFromString` | `Duration` | Decodes human-readable duration strings | | `Config.Port` | `number` | Integer in 1–65535 | | `Config.LogLevel` | `string` | One of the standard log level literals | | `Config.Record(key, value)` | `Record` | Also parses flat `"k1=v1,k2=v2"` strings | ## ConfigProvider Sources The concrete built-in source providers `fromEnv`, `fromDotEnvContents`, `fromDotEnv`, `fromUnknown`, and `fromDir` treat literal empty strings as missing values by default when they are loaded as values. Container discovery still reflects the source structure, so a key or file can appear in a `Record` or `Array` node and then load as missing. Pass `{ preserveEmptyStrings: true }` to preserve empty strings as explicit values. ### `ConfigProvider.fromEnv` — Environment Variables (Default) This is the default provider. Path segments are joined with `_` for lookup. ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromEnv({ env: { DATABASE_HOST: "localhost", DATABASE_PORT: "5432" } }) const host = Config.string("HOST").parse( provider.pipe(ConfigProvider.nested("DATABASE")) ) Effect.runSync(host) // "localhost" ``` **How `_` splitting works**: env var names are split on `_` to build a tree. This means `DATABASE_HOST=localhost` is accessible at both `["DATABASE_HOST"]` (flat) and `["DATABASE", "HOST"]` (nested). Querying `["DATABASE"]` returns a Record node with child key `"HOST"`. Pass `{ env: { ... } }` for testing. Omit to use `process.env` (merged with `import.meta.env` when available). ### `ConfigProvider.fromUnknown` — Plain JS Objects Ideal for testing or embedding config in code: ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromUnknown({ database: { host: "localhost", port: 5432, credentials: { username: "admin", password: "secret" } }, servers: ["server1", "server2", "server3"] }) ``` Path traversal follows standard JS rules: string segments index into object keys, numeric segments index into arrays. Primitive values are automatically stringified. ### `ConfigProvider.fromDotEnvContents` — Parse `.env` Strings When you already have the `.env` content as a string: ```ts import { ConfigProvider } from "effect" const contents = ` # Database settings HOST=localhost PORT=3000 SECRET="my-secret-value" ` const provider = ConfigProvider.fromDotEnvContents(contents) ``` Supports `export` prefixes, single/double/backtick quoting, inline comments, and escaped newlines. Enable variable expansion with `{ expandVariables: true }`: ```ts import { ConfigProvider } from "effect" const contents = ` PASSWORD=secret DB_PASS=$PASSWORD ` const provider = ConfigProvider.fromDotEnvContents(contents, { expandVariables: true }) ``` ### `ConfigProvider.fromDotEnv` — Load `.env` Files Reads a `.env` file from disk. Returns an `Effect` (requires `FileSystem` in context): ```ts import { ConfigProvider, Effect } from "effect" const program = Effect.gen(function*() { const provider = yield* ConfigProvider.fromDotEnv() // or: yield* ConfigProvider.fromDotEnv({ path: "/custom/.env" }) return provider }) ``` ### `ConfigProvider.fromDir` — Directory Trees Reads config from a file-system tree where each file is a leaf and each directory is a container. Useful for Kubernetes ConfigMap/Secret volume mounts. ``` /etc/myapp/ database/ host # contains "localhost" port # contains "5432" api_key # contains "sk-abc123" ``` ```ts import { ConfigProvider, Effect } from "effect" const program = Effect.gen(function*() { const provider = yield* ConfigProvider.fromDir({ rootPath: "/etc/myapp" }) return provider }) ``` Requires `Path` and `FileSystem` in the Effect context. Missing files and directories return `undefined`, so fallback providers can handle the path. Empty files also return `undefined` by default after trimming their contents, while directory listings still report the file names present on disk; pass `{ preserveEmptyStrings: true }` to preserve them as `Value("")`. Other file-system failures are reported as `SourceError`. ### `ConfigProvider.make` — Custom Sources Build a provider from any backing store: ```ts import { ConfigProvider, Effect } from "effect" const data: Record = { host: "localhost", port: "5432" } const provider = ConfigProvider.make((path) => { const key = path.join(".") const value = data[key] return Effect.succeed( value !== undefined ? ConfigProvider.makeValue(value) : undefined ) }) ``` Return `undefined` for "not found". Only fail with `SourceError` for actual I/O errors. ## ConfigProvider Combinators ### `ConfigProvider.orElse` — Fallback Sources Falls back to a second provider when the first returns `undefined` (path not found). Does **not** catch `SourceError`. ```ts import { ConfigProvider } from "effect" const envProvider = ConfigProvider.fromEnv({ env: { HOST: "prod.example.com" } }) const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000" }) const combined = ConfigProvider.orElse(envProvider, defaults) ``` Each side keeps its own path transformations. If you combine providers that were already scoped or mapped, those transformations remain local to that side: ```ts import { ConfigProvider } from "effect" const envProvider = ConfigProvider.fromEnv({ env: { DATABASE_HOST: "localhost" } }).pipe(ConfigProvider.constantCase) const defaults = ConfigProvider.fromEnv({ env: { APP_PORT: "3000" } }).pipe(ConfigProvider.nested("APP")) const combined = envProvider.pipe(ConfigProvider.orElse(defaults)) ``` ### `ConfigProvider.nested` — Prefix All Lookups Prepends path segments so that all lookups are scoped: ```ts import { ConfigProvider } from "effect" const provider = ConfigProvider.fromEnv({ env: { APP_HOST: "localhost", APP_PORT: "3000" } }) // Lookups for ["HOST"] now resolve to ["APP", "HOST"] const scoped = ConfigProvider.nested(provider, "APP") ``` Accepts a single string or a full `Path` array. Provider transformations compose in application order. A later `nested` becomes the outer prefix: ```ts import { ConfigProvider } from "effect" const provider = ConfigProvider.fromEnv({ env: { B_A_KEY: "value" } }).pipe( ConfigProvider.nested("A"), ConfigProvider.nested("B") ) // path ["KEY"] resolves to ["B", "A", "KEY"] ``` When `nested` is applied to a provider built with `ConfigProvider.orElse`, the prefix is applied to both operands. ### `ConfigProvider.constantCase` — CamelCase to SCREAMING_SNAKE_CASE Bridges camelCase schema keys to environment variable naming: ```ts import { ConfigProvider } from "effect" const provider = ConfigProvider.fromEnv({ env: { DATABASE_HOST: "localhost" } }).pipe(ConfigProvider.constantCase) // path ["databaseHost"] now resolves to ["DATABASE_HOST"] ``` Ordering matters with `nested`. `constantCase` is a path transform, so it only converts the path it receives at that point in the pipeline: ```ts import { ConfigProvider } from "effect" const convertedPrefix = ConfigProvider.fromEnv({ env: { APP_HOST: "localhost" } }).pipe( ConfigProvider.nested("app"), ConfigProvider.constantCase ) // path ["host"] resolves to ["APP", "HOST"] const literalPrefix = ConfigProvider.fromEnv({ env: { app_HOST: "localhost" } }).pipe( ConfigProvider.constantCase, ConfigProvider.nested("app") ) // path ["host"] resolves to ["app", "HOST"] ``` Put `constantCase` after `nested` when the prefix should be converted too. ### `ConfigProvider.mapInput` — Arbitrary Path Transforms Transform the whole path before lookup: ```ts import { ConfigProvider } from "effect" const provider = ConfigProvider.fromEnv({ env: { APP_HOST: "localhost" } }) const upper = ConfigProvider.mapInput( provider, (path) => path.map((seg) => typeof seg === "string" ? seg.toUpperCase() : seg) ) ``` `mapInput` runs after earlier provider transformations, so it sees the full path produced so far: ```ts import { ConfigProvider } from "effect" const appendLeaf = ConfigProvider.mapInput((path) => [...path, "leaf"]) const provider = ConfigProvider.fromEnv({ env: { APP_KEY_leaf: "value" } }).pipe( ConfigProvider.nested("APP"), appendLeaf ) // path ["KEY"] resolves to ["APP", "KEY", "leaf"] ``` When `mapInput` is applied to a provider built with `ConfigProvider.orElse`, the mapping is applied to both operands. ## Installing a Provider ### Using `ConfigProvider.layer` Replaces the active provider for all downstream effects: ```ts import { Config, ConfigProvider, Effect } from "effect" const TestLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ port: 8080 }) ) const program = Effect.gen(function*() { const port = yield* Config.int("port") return port }) Effect.runSync(Effect.provide(program, TestLayer)) // 8080 ``` ### Using `ConfigProvider.layerAdd` Adds a provider without replacing the existing one. By default, the new provider is a **fallback**: ```ts import { ConfigProvider } from "effect" const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000" }) // process.env is tried first; `defaults` is the fallback const DefaultsLayer = ConfigProvider.layerAdd(defaults) ``` Set `{ asPrimary: true }` to make the new provider the primary source instead. ### Using `Effect.provideService` For one-off overrides without layers: ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromUnknown({ HOST: "localhost" }) const program = Effect.gen(function*() { const host = yield* Config.string("HOST") return host }).pipe( Effect.provideService(ConfigProvider.ConfigProvider, provider) ) ``` ## Two Ways to Run a Config 1. **Yield in `Effect.gen`** — automatically uses the current `ConfigProvider` from the service map: ```ts const program = Effect.gen(function*() { const host = yield* Config.string("HOST") }) ``` 2. **Call `.parse(provider)` directly** — useful for testing or when you have a specific provider: ```ts const host = Config.string("HOST") const result = Effect.runSync(host.parse(provider)) ``` ## Error Handling Config operations fail with `ConfigError`, which wraps either: - **`SourceError`** — the provider could not read data (I/O failure, permission error). Has `message` and optional `cause` properties. - **`SchemaError`** — data was found but didn't match the schema (wrong type, out of range, missing key). Check `error.cause._tag` to distinguish: ```ts import { Config, ConfigProvider, Effect } from "effect" const program = Config.int("PORT").parse( ConfigProvider.fromUnknown({ PORT: "not-a-number" }) ).pipe( Effect.tapError((error) => Effect.sync(() => { if (error.cause._tag === "SchemaError") { console.log("Validation failed:", error.message) } else { console.log("Source error:", error.message) } }) ) ) ``` **Important**: `Config.withDefault` and `Config.option` only recover from missing-data errors. Validation errors still propagate. ## Practical Example: Web Server Config ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" // Define your config shape const ServerConfig = Config.schema( Schema.Struct({ host: Schema.String, port: Schema.Int, logLevel: Schema.Literals(["debug", "info", "warn", "error"]) }), "server" ) const DbConfig = Config.schema( Schema.Struct({ url: Schema.String, poolSize: Schema.Int }), "db" ) const AppConfig = Config.all({ server: ServerConfig, db: DbConfig, debug: Config.boolean("debug").pipe(Config.withDefault(false)) }) // In production, just yield it — reads from process.env const program = Effect.gen(function*() { const config = yield* AppConfig console.log(config) }) // For testing, provide a specific provider const testProvider = ConfigProvider.fromUnknown({ server: { host: "localhost", port: 3000, logLevel: "debug" }, db: { url: "postgres://localhost/testdb", poolSize: 5 }, debug: true }) Effect.runSync( program.pipe(Effect.provide(ConfigProvider.layer(testProvider))) ) ``` With environment variables, the same config reads: ``` server_host=localhost server_port=3000 server_logLevel=debug db_url=postgres://localhost/mydb db_poolSize=10 debug=true ```