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,.envfiles, 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
Section titled “Getting Started”Reading a Single Value
Section titled “Reading a Single Value”The simplest case: read one value from an environment variable.
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.envWhen you yield a Config inside Effect.gen, it automatically uses the default ConfigProvider (which reads from process.env).
Reading Multiple Values
Section titled “Reading Multiple Values”Use Config.all to group related keys:
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
Section titled “Reading Structured Config with a Schema”For larger configs, use Config.schema with a Schema.Struct:
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
Section titled “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<string> |
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
Section titled “Config Combinators”Config.withDefault — Fallback for Missing Keys
Section titled “Config.withDefault — Fallback for Missing Keys”Only triggers when data is missing. Validation errors (wrong type, out of range) still propagate.
import { Config, ConfigProvider, Effect } from "effect"
const port = Config.int("port").pipe(Config.withDefault(3000))
const provider = ConfigProvider.fromUnknown({})Effect.runSync(port.parse(provider)) // 3000Config.option — Optional Values
Section titled “Config.option — Optional Values”Returns Option.some(value) on success and Option.none() when data is missing.
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
Section titled “Config.map — Transform a Value”import { Config } from "effect"
const upperHost = Config.string("HOST").pipe( Config.map((s) => s.toUpperCase()))Config.orElse — Fallback on Any Error
Section titled “Config.orElse — Fallback on Any Error”Unlike withDefault, this catches all ConfigErrors:
import { Config } from "effect"
const host = Config.string("HOST").pipe( Config.orElse(() => Config.succeed("localhost")))Config.nested — Scope a Config Under a Prefix
Section titled “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:
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:
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:
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
Section titled “Config.all — Combine Multiple Configs”Accepts a record or a tuple:
import { Config } from "effect"
// As a recordconst appConfig = Config.all({ host: Config.string("host"), port: Config.int("port"), debug: Config.boolean("debug")})
// As a tupleconst pair = Config.all([Config.string("a"), Config.int("b")])Custom Config Logic
Section titled “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
Section titled “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<K, V> |
Also parses flat "k1=v1,k2=v2" strings |
ConfigProvider Sources
Section titled “ConfigProvider Sources”ConfigProvider.fromEnv — Environment Variables (Default)
Section titled “ConfigProvider.fromEnv — Environment Variables (Default)”This is the default provider. Path segments are joined with _ for lookup.
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
Section titled “ConfigProvider.fromUnknown — Plain JS Objects”Ideal for testing or embedding config in code:
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
Section titled “ConfigProvider.fromDotEnvContents — Parse .env Strings”When you already have the .env content as a string:
import { ConfigProvider } from "effect"
const contents = `# Database settingsHOST=localhostPORT=3000SECRET="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 }:
import { ConfigProvider } from "effect"
const contents = `PASSWORD=secretDB_PASS=$PASSWORD`
const provider = ConfigProvider.fromDotEnvContents(contents, { expandVariables: true})ConfigProvider.fromDotEnv — Load .env Files
Section titled “ConfigProvider.fromDotEnv — Load .env Files”Reads a .env file from disk. Returns an Effect (requires FileSystem in context):
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
Section titled “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"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. Other file-system failures are reported as SourceError.
ConfigProvider.make — Custom Sources
Section titled “ConfigProvider.make — Custom Sources”Build a provider from any backing store:
import { ConfigProvider, Effect } from "effect"
const data: Record<string, string> = { 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
Section titled “ConfigProvider Combinators”ConfigProvider.orElse — Fallback Sources
Section titled “ConfigProvider.orElse — Fallback Sources”Falls back to a second provider when the first returns undefined (path not found). Does not catch SourceError.
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:
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
Section titled “ConfigProvider.nested — Prefix All Lookups”Prepends path segments so that all lookups are scoped:
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:
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
Section titled “ConfigProvider.constantCase — CamelCase to SCREAMING_SNAKE_CASE”Bridges camelCase schema keys to environment variable naming:
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:
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
Section titled “ConfigProvider.mapInput — Arbitrary Path Transforms”Transform the whole path before lookup:
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:
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
Section titled “Installing a Provider”Using ConfigProvider.layer
Section titled “Using ConfigProvider.layer”Replaces the active provider for all downstream effects:
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)) // 8080Using ConfigProvider.layerAdd
Section titled “Using ConfigProvider.layerAdd”Adds a provider without replacing the existing one. By default, the new provider is a fallback:
import { ConfigProvider } from "effect"
const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000"})
// process.env is tried first; `defaults` is the fallbackconst DefaultsLayer = ConfigProvider.layerAdd(defaults)Set { asPrimary: true } to make the new provider the primary source instead.
Using Effect.provideService
Section titled “Using Effect.provideService”For one-off overrides without layers:
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
Section titled “Two Ways to Run a Config”-
Yield in
Effect.gen— automatically uses the currentConfigProviderfrom the service map:const program = Effect.gen(function*() {const host = yield* Config.string("HOST")}) -
Call
.parse(provider)directly — useful for testing or when you have a specific provider:const host = Config.string("HOST")const result = Effect.runSync(host.parse(provider))
Error Handling
Section titled “Error Handling”Config operations fail with ConfigError, which wraps either:
SourceError— the provider could not read data (I/O failure, permission error). Hasmessageand optionalcauseproperties.SchemaError— data was found but didn’t match the schema (wrong type, out of range, missing key).
Check error.cause._tag to distinguish:
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
Section titled “Practical Example: Web Server Config”import { Config, ConfigProvider, Effect, Schema } from "effect"
// Define your config shapeconst 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.envconst program = Effect.gen(function*() { const config = yield* AppConfig console.log(config)})
// For testing, provide a specific providerconst 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=localhostserver_port=3000server_logLevel=debugdb_url=postgres://localhost/mydbdb_poolSize=10debug=true