| 1 | # Configuration in Effect |
| 2 | |
| 3 | This guide shows you how to load and validate configuration in an Effect application. Two modules work together: |
| 4 | |
| 5 | - **`ConfigProvider`** — reads raw data from a source (environment variables, JSON objects, `.env` files, directory trees). |
| 6 | - **`Config`** — describes what shape and types you expect, then decodes the raw data into typed values. |
| 7 | |
| 8 | You describe _what_ you need with `Config`, and the library figures out _how_ to read and validate it using a `ConfigProvider`. |
| 9 | |
| 10 | ## Getting Started |
| 11 | |
| 12 | ### Reading a Single Value |
| 13 | |
| 14 | The simplest case: read one value from an environment variable. |
| 15 | |
| 16 | ```ts |
| 17 | import { Config, Effect } from "effect" |
| 18 | |
| 19 | const program = Effect.gen(function*() { |
| 20 | const host = yield* Config.String("HOST") |
| 21 | console.log(host) |
| 22 | }) |
| 23 | |
| 24 | Effect.runSync(program) |
| 25 | // reads HOST from process.env |
| 26 | ``` |
| 27 | |
| 28 | When you yield a `Config` inside `Effect.gen`, it automatically uses the default `ConfigProvider` (which reads from `process.env`). |
| 29 | |
| 30 | ### Reading Multiple Values |
| 31 | |
| 32 | Use `Config.all` to group related keys: |
| 33 | |
| 34 | ```ts |
| 35 | import { Config, ConfigProvider, Effect } from "effect" |
| 36 | |
| 37 | const dbConfig = Config.all({ |
| 38 | host: Config.String("host"), |
| 39 | port: Config.Int("port") |
| 40 | }) |
| 41 | |
| 42 | const provider = ConfigProvider.fromUnknown({ |
| 43 | host: "localhost", |
| 44 | port: 5432 |
| 45 | }) |
| 46 | |
| 47 | const result = Effect.runSync(dbConfig.parse(provider)) |
| 48 | // { host: "localhost", port: 5432 } |
| 49 | ``` |
| 50 | |
| 51 | ### Reading Structured Config with a Schema |
| 52 | |
| 53 | For larger configs, use `Config.schema` with a `Schema.Struct`: |
| 54 | |
| 55 | ```ts |
| 56 | import { Config, ConfigProvider, Effect, Schema } from "effect" |
| 57 | |
| 58 | const AppConfig = Config.schema( |
| 59 | Schema.Struct({ |
| 60 | host: Schema.String, |
| 61 | port: Schema.Int, |
| 62 | debug: Schema.Boolean |
| 63 | }) |
| 64 | ) |
| 65 | |
| 66 | const provider = ConfigProvider.fromUnknown({ |
| 67 | host: "localhost", |
| 68 | port: 8080, |
| 69 | debug: true |
| 70 | }) |
| 71 | |
| 72 | const result = Effect.runSync(AppConfig.parse(provider)) |
| 73 | // { host: "localhost", port: 8080, debug: true } |
| 74 | ``` |
| 75 | |
| 76 | 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`. |
| 77 | |
| 78 | ## Config Constructors |
| 79 | |
| 80 | Each constructor reads and decodes a configuration value into the appropriate type. |
| 81 | |
| 82 | | Constructor | Decoded type | Notes | |
| 83 | | -------------------------------- | ------------------ | ------------------------------------------------------------------------ | |
| 84 | | `Config.String(name?)` | `string` | Any string | |
| 85 | | `Config.NonEmptyString(name?)` | `string` | Rejects `""` | |
| 86 | | `Config.Number(name?)` | `number` | Includes `NaN`, `Infinity` | |
| 87 | | `Config.Finite(name?)` | `number` | Rejects `NaN` and `Infinity` | |
| 88 | | `Config.Int(name?)` | `number` | Integers only | |
| 89 | | `Config.Boolean(name?)` | `boolean` | Accepts `true/false`, `yes/no`, `on/off`, `1/0`, `y/n` | |
| 90 | | `Config.Port(name?)` | `number` | Integer in 1–65535 | |
| 91 | | `Config.URL(name?)` | `URL` | Parsed via the `URL` constructor | |
| 92 | | `Config.Date(name?)` | `Date` | Rejects invalid dates | |
| 93 | | `Config.Duration(name?)` | `Duration` | Parses `"10 seconds"`, `"500 millis"`, `"Infinity"`, `"-Infinity"`, etc. | |
| 94 | | `Config.LogLevel(name?)` | `string` | One of `All`, `Fatal`, `Error`, `Warn`, `Info`, `Debug`, `Trace`, `None` | |
| 95 | | `Config.Redacted(name?)` | `Redacted<string>` | Hidden from logs and `toString` | |
| 96 | | `Config.Literal(value, name?)` | literal type | Accepts only the given literal | |
| 97 | | `Config.Literals(values, name?)` | literal union | Accepts one of the given literals | |
| 98 | | `Config.Array(value, ...)` | `ReadonlyArray<V>` | Accepts structural arrays and flat separated strings | |
| 99 | | `Config.Record(key, value, ...)` | `Record<K, V>` | Accepts structural records and flat separated key-value strings | |
| 100 | |
| 101 | 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.Array` and `Config.Record` additionally accept an options object directly when no path is needed, or a path followed by the options object. |
| 102 | |
| 103 | ### Parsing and Path Ownership |
| 104 | |
| 105 | A `Config` exposes `parse(provider)`; lookup prefixes are not part of this public method. Build paths declaratively with the constructor's `name` / `path` argument and `Config.nested`. |
| 106 | |
| 107 | This keeps the two path responsibilities separate: |
| 108 | |
| 109 | - `Config.schema(..., path)` and `Config.nested(name)` describe the logical path of a setting. |
| 110 | - `ConfigProvider.mapInput`, `ConfigProvider.nested`, and case-conversion combinators map logical paths to a source. |
| 111 | |
| 112 | The same rule applies when a `Config` is yielded as an `Effect`: the config uses the current `ConfigProvider`, while its internally composed logical path stays an implementation detail. |
| 113 | |
| 114 | ## Config Combinators |
| 115 | |
| 116 | ### `Config.withDefault` — Fallback for Absent Input |
| 117 | |
| 118 | Triggers when the config is absent. For `Config.all`, any absent child makes the group absent unless another child fails. A group default replaces the entire group; apply defaults to individual children to keep other supplied values. Validation and source errors still propagate. |
| 119 | |
| 120 | ```ts |
| 121 | import { Config, ConfigProvider, Effect } from "effect" |
| 122 | |
| 123 | const port = Config.Int("port").pipe(Config.withDefault(3000)) |
| 124 | |
| 125 | const provider = ConfigProvider.fromUnknown({}) |
| 126 | Effect.runSync(port.parse(provider)) // 3000 |
| 127 | ``` |
| 128 | |
| 129 | ### `Config.option` — Optional Values |
| 130 | |
| 131 | Returns `Option.some(value)` on success and `Option.none()` when the config is absent. A successful `undefined` value is still a success, so a schema that accepts missing input produces `Option.some(undefined)`, not `Option.none()`. |
| 132 | |
| 133 | ```ts |
| 134 | import { Config, ConfigProvider, Effect } from "effect" |
| 135 | |
| 136 | const maybePort = Config.option(Config.Int("port")) |
| 137 | |
| 138 | const provider = ConfigProvider.fromUnknown({}) |
| 139 | Effect.runSync(maybePort.parse(provider)) // { _tag: "None" } |
| 140 | ``` |
| 141 | |
| 142 | ### `Config.map` — Transform a Value |
| 143 | |
| 144 | ```ts |
| 145 | import { Config } from "effect" |
| 146 | |
| 147 | const upperHost = Config.String("HOST").pipe( |
| 148 | Config.map((s) => s.toUpperCase()) |
| 149 | ) |
| 150 | ``` |
| 151 | |
| 152 | ### `Config.orElse` — Fallback on Any Error |
| 153 | |
| 154 | Unlike `withDefault`, this handles absence and **all** `ConfigError`s. The fallback's result replaces the original failure. If the fallback is absent, a later `withDefault` or `option` can recover it. If the fallback fails, only its error propagates: |
| 155 | |
| 156 | ```ts |
| 157 | import { Config } from "effect" |
| 158 | |
| 159 | const host = Config.String("HOST").pipe( |
| 160 | Config.orElse(() => Config.succeed("localhost")) |
| 161 | ) |
| 162 | ``` |
| 163 | |
| 164 | ### `Config.nested` — Scope a Config Under a Prefix |
| 165 | |
| 166 | Prepends a logical path segment to every key the inner config reads. The prefix is used for both provider lookups and schema error paths: |
| 167 | |
| 168 | ```ts |
| 169 | import { Config, ConfigProvider, Effect } from "effect" |
| 170 | |
| 171 | const dbConfig = Config.all({ |
| 172 | host: Config.String("host"), |
| 173 | port: Config.Int("port") |
| 174 | }).pipe(Config.nested("database")) |
| 175 | |
| 176 | const provider = ConfigProvider.fromUnknown({ |
| 177 | database: { host: "localhost", port: 5432 } |
| 178 | }) |
| 179 | |
| 180 | Effect.runSync(dbConfig.parse(provider)) |
| 181 | // { host: "localhost", port: 5432 } |
| 182 | ``` |
| 183 | |
| 184 | With environment variables, nesting uses `_` as separator: |
| 185 | |
| 186 | ```ts |
| 187 | import { Config, ConfigProvider, Effect } from "effect" |
| 188 | |
| 189 | const host = Config.String("host").pipe(Config.nested("database")) |
| 190 | |
| 191 | const provider = ConfigProvider.fromEnv({ |
| 192 | env: { database_host: "localhost" } |
| 193 | }) |
| 194 | |
| 195 | Effect.runSync(host.parse(provider)) // "localhost" |
| 196 | ``` |
| 197 | |
| 198 | Multiple `Config.nested` calls compose with the outermost prefix first: |
| 199 | |
| 200 | ```ts |
| 201 | import { Config, ConfigProvider, Effect } from "effect" |
| 202 | |
| 203 | const config = Config.String("host").pipe( |
| 204 | Config.nested("database"), |
| 205 | Config.nested("production") |
| 206 | ) |
| 207 | |
| 208 | const provider = ConfigProvider.fromUnknown({ |
| 209 | production: { |
| 210 | database: { |
| 211 | host: "localhost" |
| 212 | } |
| 213 | } |
| 214 | }) |
| 215 | |
| 216 | Effect.runSync(config.parse(provider)) // "localhost" |
| 217 | ``` |
| 218 | |
| 219 | ### `Config.all` — Combine Multiple Configs |
| 220 | |
| 221 | Accepts a record, a tuple, or an iterable. A group is absent when any child is absent and no child fails. `Config.withDefault` replaces the entire absent group, and `Config.option` returns `None`. Validation and source errors propagate even when another child is absent. |
| 222 | |
| 223 | ```ts |
| 224 | import { Config } from "effect" |
| 225 | |
| 226 | // As a record |
| 227 | const appConfig = Config.all({ |
| 228 | host: Config.String("host"), |
| 229 | port: Config.Int("port"), |
| 230 | debug: Config.Boolean("debug") |
| 231 | }) |
| 232 | |
| 233 | // As a tuple |
| 234 | const pair = Config.all([Config.String("a"), Config.Int("b")]) |
| 235 | ``` |
| 236 | |
| 237 | For example, providing only `host` selects the default for the entire group here: |
| 238 | |
| 239 | ```ts |
| 240 | import { Config } from "effect" |
| 241 | |
| 242 | const database = Config.all({ |
| 243 | host: Config.String("host"), |
| 244 | port: Config.Int("port") |
| 245 | }).pipe( |
| 246 | Config.withDefault({ host: "localhost", port: 5432 }) |
| 247 | ) |
| 248 | ``` |
| 249 | |
| 250 | If the provider contains `{ host: "db.internal" }`, the result is `{ host: "localhost", port: 5432 }`. To keep the supplied host and default only the port, put `Config.withDefault(5432)` on the `port` child instead. |
| 251 | |
| 252 | Child defaults resolve missing children before the group decides whether it is absent: |
| 253 | |
| 254 | ```ts |
| 255 | const listener = Config.all({ |
| 256 | host: Config.String("host"), |
| 257 | port: Config.Int("port").pipe(Config.withDefault(8080)) |
| 258 | }).pipe(Config.option) |
| 259 | ``` |
| 260 | |
| 261 | `listener` is `None` when `host` is absent, including when a valid `port` is present. It is `Some` when `host` is present and `port` resolves to either a supplied value or its default. An invalid `port` fails even if `host` is absent. |
| 262 | |
| 263 | ### How Absence Is Decided |
| 264 | |
| 265 | Configuration evaluation distinguishes three situations before producing the public `Effect`: |
| 266 | |
| 267 | 1. **Resolved** — decoding succeeded. The value may legitimately be `undefined`, `{}`, or `[]`. |
| 268 | 2. **Absent** — a schema could not resolve because its relevant provider representation was unavailable, or a combined config has an absent child and no failed children. |
| 269 | 3. **Failed** — the provider failed, input was invalid, or an effectful transformation failed. |
| 270 | |
| 271 | `Config.withDefault` and `Config.option` handle only the second case. `Config.orElse` handles both absence and failures and adopts the fallback's result. Once a failure is recovered, it does not influence enclosing groups or later defaults. |
| 272 | |
| 273 | At the lookup path of a `Config.schema`, an unavailable representation is passed to the schema decoder as `undefined`. This includes a missing node and a present node whose shape cannot represent the schema: for example, an array node cannot represent a struct. Missing properties inside an object remain omitted so the schema's property semantics still apply. The decoder runs before absence is decided. Consequently: |
| 274 | |
| 275 | - `Config.schema(Schema.UndefinedOr(Schema.String), "key")` succeeds with `undefined` when `key` is absent. |
| 276 | - An explicitly present empty object can decode to `{}` when the schema permits it. |
| 277 | - Wrapping either successful result in `Config.option` produces `Some`, because decoding succeeded. |
| 278 | - If the schema rejects `undefined` and no relevant representation was found, `Config.withDefault` uses its fallback and `Config.option` returns `None`. |
| 279 | - Present invalid data is a failure. A `Config.all` group with missing children is absent unless another child fails. |
| 280 | - `SourceError` is always a failure and is never replaced by `withDefault` or `option`. |
| 281 | |
| 282 | `Config.schema(Schema.Struct(...))` and `Config.all(...)` describe different lookup models. A struct schema validates one structured input. An existing object, including an empty object, fails validation if required fields are missing. `Config.all` evaluates independent child configs and can recover a partially supplied group through `Config.withDefault` or `Config.option`. Field optionality in `Config.all` is expressed on each child with `Config.option` or `Config.withDefault`. |
| 283 | |
| 284 | ### How Schema Input Is Loaded |
| 285 | |
| 286 | `Config.schema` converts its codec to the canonical `Schema.StringTree` codec and uses the encoded AST to decide which provider representation to load: |
| 287 | |
| 288 | - A scalar schema reads the node's scalar value. A record or array node may have a co-located scalar value in addition to its children. |
| 289 | - A struct loads its declared properties and omits children that the provider does not contain. A record schema also loads advertised keys that match its index signature. |
| 290 | - An array or tuple loads its indexed children. Missing positions are represented as `undefined` so the element schema decides whether they are valid. |
| 291 | - A union whose members require different shapes materializes each member independently. Schema then applies the union's declared order or `oneOf` rule and any checks attached to the original union. |
| 292 | |
| 293 | This keeps the provider responsible only for reporting what exists. Schema remains responsible for deciding whether the loaded representation is valid. |
| 294 | |
| 295 | Plain `Schema.Array` and `Schema.Record` accept structural provider input only. Use `Config.Array(Schema.String, "items")` for separated scalar input such as `"a,b,c"`, and `Config.Record(Schema.String, Schema.String, "items")` for input such as `"a=1,b=2"`. Both constructors also accept structural input. |
| 296 | |
| 297 | The canonical `StringTree` encoding must expose a concrete scalar, object, array, or union shape. `Config.schema` rejects opaque encodings such as `Schema.Any`, `Schema.Unknown`, `Schema.ObjectKeyword`, `Schema.Json`, and `Schema.MutableJson` synchronously when the config is constructed, including when they are nested in another schema. Suspended recursive schemas and declarations such as `Schema.URL` remain supported when their eventual canonical encoding has a concrete shape. To read arbitrary JSON from one scalar provider value, use `Schema.fromJsonString(Schema.Json)`. |
| 298 | |
| 299 | ### Custom Config Logic |
| 300 | |
| 301 | 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.mapEffect`, `Config.all`, `Config.orElse`, or `Config.withDefault`. |
| 302 | |
| 303 | If you need custom lookup behavior for a new backing source, implement a `ConfigProvider` with `ConfigProvider.make` instead. |
| 304 | |
| 305 | ## Array and Record Constructors |
| 306 | |
| 307 | `Config.Array` accepts `{ separator? }`, while `Config.Record` accepts `{ separator?, keyValueSeparator? }`. Pass the options object directly to read the provider root, or place a string or `ConfigProvider.Path` before it to select a path: |
| 308 | |
| 309 | ```ts |
| 310 | import { Config, Schema } from "effect" |
| 311 | |
| 312 | const rootValues = Config.Array(Schema.String, { separator: ";" }) |
| 313 | const namedValues = Config.Array(Schema.String, "VALUES", { separator: ";" }) |
| 314 | |
| 315 | const rootHeaders = Config.Record(Schema.String, Schema.String, { keyValueSeparator: ":" }) |
| 316 | const namedHeaders = Config.Record(Schema.String, Schema.String, "HEADERS", { keyValueSeparator: ":" }) |
| 317 | ``` |
| 318 | |
| 319 | ## ConfigProvider Sources |
| 320 | |
| 321 | 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. |
| 322 | |
| 323 | At the raw provider interface, `load(path)` succeeds with `Node | undefined`: a |
| 324 | `Node` means the path exists, while `undefined` means it does not. A |
| 325 | `SourceError` represents a failure to read the source and remains in the Effect |
| 326 | error channel. |
| 327 | |
| 328 | Lookup-level `undefined` is distinct from the `value` field of a found `Record` |
| 329 | or `Array` node. Such a container can exist while |
| 330 | `node.value === undefined`, which means that it has children but no co-located |
| 331 | scalar value. |
| 332 | |
| 333 | ### `ConfigProvider.fromEnv` — Environment Variables (Default) |
| 334 | |
| 335 | This is the default provider. Path segments are joined with `_` for lookup. |
| 336 | |
| 337 | ```ts |
| 338 | import { Config, ConfigProvider, Effect } from "effect" |
| 339 | |
| 340 | const provider = ConfigProvider.fromEnv({ |
| 341 | env: { |
| 342 | DATABASE_HOST: "localhost", |
| 343 | DATABASE_PORT: "5432" |
| 344 | } |
| 345 | }) |
| 346 | |
| 347 | const host = Config.String("HOST").parse( |
| 348 | provider.pipe(ConfigProvider.nested("DATABASE")) |
| 349 | ) |
| 350 | |
| 351 | Effect.runSync(host) // "localhost" |
| 352 | ``` |
| 353 | |
| 354 | **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"`. |
| 355 | |
| 356 | Pass `{ env: { ... } }` for testing. Omit to use `process.env` (merged with `import.meta.env` when available). |
| 357 | |
| 358 | ### `ConfigProvider.fromUnknown` — Plain JS Objects |
| 359 | |
| 360 | Ideal for testing or embedding config in code: |
| 361 | |
| 362 | ```ts |
| 363 | import { Config, ConfigProvider, Effect } from "effect" |
| 364 | |
| 365 | const provider = ConfigProvider.fromUnknown({ |
| 366 | database: { |
| 367 | host: "localhost", |
| 368 | port: 5432, |
| 369 | credentials: { |
| 370 | username: "admin", |
| 371 | password: "secret" |
| 372 | } |
| 373 | }, |
| 374 | servers: ["server1", "server2", "server3"] |
| 375 | }) |
| 376 | ``` |
| 377 | |
| 378 | Path traversal follows standard JS rules: string segments index into object keys, numeric segments index into arrays. Primitive values are automatically stringified. |
| 379 | |
| 380 | ### `ConfigProvider.fromDotEnvContents` — Parse `.env` Strings |
| 381 | |
| 382 | When you already have the `.env` content as a string: |
| 383 | |
| 384 | ```ts |
| 385 | import { ConfigProvider } from "effect" |
| 386 | |
| 387 | const contents = ` |
| 388 | # Database settings |
| 389 | HOST=localhost |
| 390 | PORT=3000 |
| 391 | SECRET="my-secret-value" |
| 392 | ` |
| 393 | |
| 394 | const provider = ConfigProvider.fromDotEnvContents(contents) |
| 395 | ``` |
| 396 | |
| 397 | Supports `export` prefixes, single/double/backtick quoting, inline comments, and escaped newlines. Enable variable expansion with `{ expandVariables: true }`: |
| 398 | |
| 399 | ```ts |
| 400 | import { ConfigProvider } from "effect" |
| 401 | |
| 402 | const contents = ` |
| 403 | PASSWORD=secret |
| 404 | DB_PASS=$PASSWORD |
| 405 | ` |
| 406 | |
| 407 | const provider = ConfigProvider.fromDotEnvContents(contents, { |
| 408 | expandVariables: true |
| 409 | }) |
| 410 | ``` |
| 411 | |
| 412 | ### `ConfigProvider.fromDotEnv` — Load `.env` Files |
| 413 | |
| 414 | Reads a `.env` file from disk. Returns an `Effect` (requires `FileSystem` in context): |
| 415 | |
| 416 | ```ts |
| 417 | import { ConfigProvider, Effect } from "effect" |
| 418 | |
| 419 | const program = Effect.gen(function*() { |
| 420 | const provider = yield* ConfigProvider.fromDotEnv() |
| 421 | // or: yield* ConfigProvider.fromDotEnv({ path: "/custom/.env" }) |
| 422 | return provider |
| 423 | }) |
| 424 | ``` |
| 425 | |
| 426 | ### `ConfigProvider.fromDir` — Directory Trees |
| 427 | |
| 428 | 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. |
| 429 | |
| 430 | ``` |
| 431 | /etc/myapp/ |
| 432 | database/ |
| 433 | host # contains "localhost" |
| 434 | port # contains "5432" |
| 435 | api_key # contains "sk-abc123" |
| 436 | ``` |
| 437 | |
| 438 | ```ts |
| 439 | import { ConfigProvider, Effect } from "effect" |
| 440 | |
| 441 | const program = Effect.gen(function*() { |
| 442 | const provider = yield* ConfigProvider.fromDir({ |
| 443 | rootPath: "/etc/myapp" |
| 444 | }) |
| 445 | return provider |
| 446 | }) |
| 447 | ``` |
| 448 | |
| 449 | Requires `Path` and `FileSystem` in the Effect context. |
| 450 | |
| 451 | 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`. |
| 452 | |
| 453 | ### `ConfigProvider.make` — Custom Sources |
| 454 | |
| 455 | Build a provider from any backing store: |
| 456 | |
| 457 | ```ts |
| 458 | import { ConfigProvider, Effect } from "effect" |
| 459 | |
| 460 | const data: Record<string, string> = { |
| 461 | host: "localhost", |
| 462 | port: "5432" |
| 463 | } |
| 464 | |
| 465 | const provider = ConfigProvider.make((path) => { |
| 466 | const key = path.join(".") |
| 467 | const value = data[key] |
| 468 | return Effect.succeed( |
| 469 | value !== undefined ? ConfigProvider.makeValue(value) : undefined |
| 470 | ) |
| 471 | }) |
| 472 | ``` |
| 473 | |
| 474 | Return `undefined` for "not found" and a `Node` for a path that exists. Only |
| 475 | fail with `SourceError` when the source itself cannot be read. Providers created |
| 476 | with `make` automatically support the path-transformation behavior used by |
| 477 | `mapInput`, `constantCase`, and `nested`. |
| 478 | |
| 479 | ## ConfigProvider Combinators |
| 480 | |
| 481 | ### `ConfigProvider.orElse` — Fallback Sources |
| 482 | |
| 483 | Falls back to a second provider when the first returns `undefined` (path not found). Does **not** catch `SourceError`. |
| 484 | |
| 485 | ```ts |
| 486 | import { ConfigProvider } from "effect" |
| 487 | |
| 488 | const envProvider = ConfigProvider.fromEnv({ |
| 489 | env: { HOST: "prod.example.com" } |
| 490 | }) |
| 491 | const defaults = ConfigProvider.fromUnknown({ |
| 492 | HOST: "localhost", |
| 493 | PORT: "3000" |
| 494 | }) |
| 495 | |
| 496 | const combined = ConfigProvider.orElse(envProvider, defaults) |
| 497 | ``` |
| 498 | |
| 499 | Each side keeps its own path transformations. If you combine providers that were already scoped or mapped, those transformations remain local to that side: |
| 500 | |
| 501 | ```ts |
| 502 | import { ConfigProvider } from "effect" |
| 503 | |
| 504 | const envProvider = ConfigProvider.fromEnv({ |
| 505 | env: { DATABASE_HOST: "localhost" } |
| 506 | }).pipe(ConfigProvider.constantCase) |
| 507 | |
| 508 | const defaults = ConfigProvider.fromEnv({ |
| 509 | env: { APP_PORT: "3000" } |
| 510 | }).pipe(ConfigProvider.nested("APP")) |
| 511 | |
| 512 | const combined = envProvider.pipe(ConfigProvider.orElse(defaults)) |
| 513 | ``` |
| 514 | |
| 515 | ### `ConfigProvider.nested` — Prefix All Lookups |
| 516 | |
| 517 | Prepends path segments so that all lookups are scoped: |
| 518 | |
| 519 | ```ts |
| 520 | import { ConfigProvider } from "effect" |
| 521 | |
| 522 | const provider = ConfigProvider.fromEnv({ |
| 523 | env: { APP_HOST: "localhost", APP_PORT: "3000" } |
| 524 | }) |
| 525 | |
| 526 | // Lookups for ["HOST"] now resolve to ["APP", "HOST"] |
| 527 | const scoped = ConfigProvider.nested(provider, "APP") |
| 528 | ``` |
| 529 | |
| 530 | Accepts a single string or a full `Path` array. |
| 531 | |
| 532 | Provider transformations compose in application order. A later `nested` becomes the outer prefix: |
| 533 | |
| 534 | ```ts |
| 535 | import { ConfigProvider } from "effect" |
| 536 | |
| 537 | const provider = ConfigProvider.fromEnv({ |
| 538 | env: { B_A_KEY: "value" } |
| 539 | }).pipe( |
| 540 | ConfigProvider.nested("A"), |
| 541 | ConfigProvider.nested("B") |
| 542 | ) |
| 543 | |
| 544 | // path ["KEY"] resolves to ["B", "A", "KEY"] |
| 545 | ``` |
| 546 | |
| 547 | When `nested` is applied to a provider built with `ConfigProvider.orElse`, the prefix is applied to both operands. |
| 548 | |
| 549 | ### `ConfigProvider.constantCase` — CamelCase to SCREAMING_SNAKE_CASE |
| 550 | |
| 551 | Bridges camelCase schema keys to environment variable naming: |
| 552 | |
| 553 | ```ts |
| 554 | import { ConfigProvider } from "effect" |
| 555 | |
| 556 | const provider = ConfigProvider.fromEnv({ |
| 557 | env: { DATABASE_HOST: "localhost" } |
| 558 | }).pipe(ConfigProvider.constantCase) |
| 559 | |
| 560 | // path ["databaseHost"] now resolves to ["DATABASE_HOST"] |
| 561 | ``` |
| 562 | |
| 563 | Ordering matters with `nested`. `constantCase` is a path transform, so it only converts the path it receives at that point in the pipeline: |
| 564 | |
| 565 | ```ts |
| 566 | import { ConfigProvider } from "effect" |
| 567 | |
| 568 | const convertedPrefix = ConfigProvider.fromEnv({ |
| 569 | env: { APP_HOST: "localhost" } |
| 570 | }).pipe( |
| 571 | ConfigProvider.nested("app"), |
| 572 | ConfigProvider.constantCase |
| 573 | ) |
| 574 | |
| 575 | // path ["host"] resolves to ["APP", "HOST"] |
| 576 | |
| 577 | const literalPrefix = ConfigProvider.fromEnv({ |
| 578 | env: { app_HOST: "localhost" } |
| 579 | }).pipe( |
| 580 | ConfigProvider.constantCase, |
| 581 | ConfigProvider.nested("app") |
| 582 | ) |
| 583 | |
| 584 | // path ["host"] resolves to ["app", "HOST"] |
| 585 | ``` |
| 586 | |
| 587 | Put `constantCase` after `nested` when the prefix should be converted too. |
| 588 | |
| 589 | ### `ConfigProvider.mapInput` — Arbitrary Path Transforms |
| 590 | |
| 591 | Transform the whole path before lookup: |
| 592 | |
| 593 | ```ts |
| 594 | import { ConfigProvider } from "effect" |
| 595 | |
| 596 | const provider = ConfigProvider.fromEnv({ |
| 597 | env: { APP_HOST: "localhost" } |
| 598 | }) |
| 599 | |
| 600 | const upper = ConfigProvider.mapInput( |
| 601 | provider, |
| 602 | (path) => path.map((seg) => typeof seg === "string" ? seg.toUpperCase() : seg) |
| 603 | ) |
| 604 | ``` |
| 605 | |
| 606 | Path transformation is a capability of the `ConfigProvider` interface. The |
| 607 | exported `ConfigProvider.mapInput` combinator delegates to that capability, |
| 608 | rather than passing an extra transformation argument to `load`. This keeps |
| 609 | ordinary lookup fixed as `load(path)` and allows composite providers to |
| 610 | preserve their own behavior without exposing representation state. Custom |
| 611 | source providers should normally be constructed with `ConfigProvider.make`, |
| 612 | which implements this capability automatically. |
| 613 | |
| 614 | `mapInput` runs after earlier provider transformations, so it sees the full path produced so far: |
| 615 | |
| 616 | ```ts |
| 617 | import { ConfigProvider } from "effect" |
| 618 | |
| 619 | const appendLeaf = ConfigProvider.mapInput((path) => [...path, "leaf"]) |
| 620 | |
| 621 | const provider = ConfigProvider.fromEnv({ |
| 622 | env: { APP_KEY_leaf: "value" } |
| 623 | }).pipe( |
| 624 | ConfigProvider.nested("APP"), |
| 625 | appendLeaf |
| 626 | ) |
| 627 | |
| 628 | // path ["KEY"] resolves to ["APP", "KEY", "leaf"] |
| 629 | ``` |
| 630 | |
| 631 | When `mapInput` is applied to a provider built with `ConfigProvider.orElse`, the mapping is applied to both operands. |
| 632 | |
| 633 | ## Installing a Provider |
| 634 | |
| 635 | ### Using `ConfigProvider.layer` |
| 636 | |
| 637 | Replaces the active provider for all downstream effects: |
| 638 | |
| 639 | ```ts |
| 640 | import { Config, ConfigProvider, Effect } from "effect" |
| 641 | |
| 642 | const TestLayer = ConfigProvider.layer( |
| 643 | ConfigProvider.fromUnknown({ port: 8080 }) |
| 644 | ) |
| 645 | |
| 646 | const program = Effect.gen(function*() { |
| 647 | const port = yield* Config.Int("port") |
| 648 | return port |
| 649 | }) |
| 650 | |
| 651 | Effect.runSync(Effect.provide(program, TestLayer)) // 8080 |
| 652 | ``` |
| 653 | |
| 654 | ### Using `ConfigProvider.layerAdd` |
| 655 | |
| 656 | Adds a provider without replacing the existing one. By default, the new provider is a **fallback**: |
| 657 | |
| 658 | ```ts |
| 659 | import { ConfigProvider } from "effect" |
| 660 | |
| 661 | const defaults = ConfigProvider.fromUnknown({ |
| 662 | HOST: "localhost", |
| 663 | PORT: "3000" |
| 664 | }) |
| 665 | |
| 666 | // process.env is tried first; `defaults` is the fallback |
| 667 | const DefaultsLayer = ConfigProvider.layerAdd(defaults) |
| 668 | ``` |
| 669 | |
| 670 | Set `{ asPrimary: true }` to make the new provider the primary source instead. |
| 671 | |
| 672 | ### Using `Effect.provideService` |
| 673 | |
| 674 | For one-off overrides without layers: |
| 675 | |
| 676 | ```ts |
| 677 | import { Config, ConfigProvider, Effect } from "effect" |
| 678 | |
| 679 | const provider = ConfigProvider.fromUnknown({ HOST: "localhost" }) |
| 680 | |
| 681 | const program = Effect.gen(function*() { |
| 682 | const host = yield* Config.String("HOST") |
| 683 | return host |
| 684 | }).pipe( |
| 685 | Effect.provideService(ConfigProvider.ConfigProvider, provider) |
| 686 | ) |
| 687 | ``` |
| 688 | |
| 689 | ## Two Ways to Run a Config |
| 690 | |
| 691 | 1. **Yield in `Effect.gen`** — automatically uses the current `ConfigProvider` from the service map: |
| 692 | |
| 693 | ```ts |
| 694 | const program = Effect.gen(function*() { |
| 695 | const host = yield* Config.String("HOST") |
| 696 | }) |
| 697 | ``` |
| 698 | |
| 699 | 2. **Call `.parse(provider)` directly** — useful for testing or when you have a specific provider: |
| 700 | |
| 701 | ```ts |
| 702 | const host = Config.String("HOST") |
| 703 | const result = Effect.runSync(host.parse(provider)) |
| 704 | ``` |
| 705 | |
| 706 | The method accepts only the provider. Use `Config.nested` or the path argument of `Config.schema` to scope lookups. |
| 707 | |
| 708 | ## Error Handling |
| 709 | |
| 710 | Config operations fail with `ConfigError`, which wraps either: |
| 711 | |
| 712 | - **`SourceError`** — the provider could not read data (I/O failure, permission error). Has `message` and optional `cause` properties. |
| 713 | - **`SchemaError`** — data was found but didn't match the schema (wrong type, out of range, missing key). |
| 714 | |
| 715 | Check `error.cause._tag` to distinguish: |
| 716 | |
| 717 | ```ts |
| 718 | import { Config, ConfigProvider, Effect } from "effect" |
| 719 | |
| 720 | const program = Config.Int("PORT").parse( |
| 721 | ConfigProvider.fromUnknown({ PORT: "not-a-number" }) |
| 722 | ).pipe( |
| 723 | Effect.tapError((error) => |
| 724 | Effect.sync(() => { |
| 725 | if (error.cause._tag === "SchemaError") { |
| 726 | console.log("Validation failed:", error.message) |
| 727 | } else { |
| 728 | console.log("Source error:", error.message) |
| 729 | } |
| 730 | }) |
| 731 | ) |
| 732 | ) |
| 733 | ``` |
| 734 | |
| 735 | **Important**: `Config.withDefault` and `Config.option` recover only from semantic absence. They do not classify `SchemaIssue` values as “missing.” Validation and source errors still propagate unless explicitly recovered with `Config.orElse`. |
| 736 | |
| 737 | ## Practical Example: Web Server Config |
| 738 | |
| 739 | ```ts |
| 740 | import { Config, ConfigProvider, Effect, Schema } from "effect" |
| 741 | |
| 742 | // Define your config shape |
| 743 | const ServerConfig = Config.schema( |
| 744 | Schema.Struct({ |
| 745 | host: Schema.String, |
| 746 | port: Schema.Int, |
| 747 | logLevel: Schema.Literals(["debug", "info", "warn", "error"]) |
| 748 | }), |
| 749 | "server" |
| 750 | ) |
| 751 | |
| 752 | const DbConfig = Config.schema( |
| 753 | Schema.Struct({ |
| 754 | url: Schema.String, |
| 755 | poolSize: Schema.Int |
| 756 | }), |
| 757 | "db" |
| 758 | ) |
| 759 | |
| 760 | const AppConfig = Config.all({ |
| 761 | server: ServerConfig, |
| 762 | db: DbConfig, |
| 763 | debug: Config.Boolean("debug").pipe(Config.withDefault(false)) |
| 764 | }) |
| 765 | |
| 766 | // In production, just yield it — reads from process.env |
| 767 | const program = Effect.gen(function*() { |
| 768 | const config = yield* AppConfig |
| 769 | console.log(config) |
| 770 | }) |
| 771 | |
| 772 | // For testing, provide a specific provider |
| 773 | const testProvider = ConfigProvider.fromUnknown({ |
| 774 | server: { host: "localhost", port: 3000, logLevel: "debug" }, |
| 775 | db: { url: "postgres://localhost/testdb", poolSize: 5 }, |
| 776 | debug: true |
| 777 | }) |
| 778 | |
| 779 | Effect.runSync( |
| 780 | program.pipe(Effect.provide(ConfigProvider.layer(testProvider))) |
| 781 | ) |
| 782 | ``` |
| 783 | |
| 784 | With environment variables, the same config reads: |
| 785 | |
| 786 | ``` |
| 787 | server_host=localhost |
| 788 | server_port=3000 |
| 789 | server_logLevel=debug |
| 790 | db_url=postgres://localhost/mydb |
| 791 | db_poolSize=10 |
| 792 | debug=true |
| 793 | ``` |