Skip to content

Config Providers

A ConfigProvider is the backing data source that a Config reads from. It knows how to resolve a path (like ["database", "host"]) to a raw value. By default Effect uses a provider backed by environment variables, but you can swap in JSON, a .env file, a directory of files, or a custom source — without touching the Config definitions that consume it.

import { Config, ConfigProvider, Effect } from "effect"
const program = Effect.gen(function* () {
const host = yield* Config.string("HOST")
const port = yield* Config.port("PORT")
yield* Effect.log(`${host}:${port}`)
})
// Provide a fixed in-memory provider instead of reading the real environment
const provider = ConfigProvider.fromEnv({
env: { HOST: "localhost", PORT: "8080" }
})
Effect.runFork(
program.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
)

ConfigProvider.ConfigProvider is a Context.Reference whose default value is fromEnv(). Because it is a reference, your program works without any explicit provision — and you override it for an entire program by providing a different one.

| Provider | Reads from | | --------------------------------------- | ----------------------------------------------------- | | ConfigProvider.fromEnv(options?) | environment variables (default; process.env) | | ConfigProvider.fromUnknown(root) | an in-memory JS value / parsed JSON object | | ConfigProvider.fromDotEnvContents(s) | the string contents of a .env file | | ConfigProvider.fromDotEnv(options?) | a .env file on disk (requires FileSystem) | | ConfigProvider.fromDir(options?) | a directory tree, file-per-key (requires FileSystem, Path) | | ConfigProvider.make(get) | a custom lookup function |

fromEnv joins path segments with _ for lookups, and also splits env var names on _ to discover nested keys. So DATABASE_HOST=localhost is reachable both at the flat path ["DATABASE_HOST"] and at the nested path ["DATABASE", "HOST"].

import { Config, ConfigProvider, Effect } from "effect"
const provider = ConfigProvider.fromEnv({
env: { DATABASE_HOST: "localhost", DATABASE_PORT: "5432" }
})
const program = Effect.gen(function* () {
// nested("DATABASE") makes these read DATABASE_HOST / DATABASE_PORT
const db = yield* Config.all({
host: Config.string("HOST"),
port: Config.port("PORT")
}).pipe(Config.nested("DATABASE"))
yield* Effect.log(`${db.host}:${db.port}`)
}).pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))

fromUnknown is backed by any JavaScript value, typically a parsed JSON object. String keys index into objects and numeric keys index into arrays, so it handles arbitrarily nested config naturally.

import { Config, ConfigProvider, Effect } from "effect"
const provider = ConfigProvider.fromUnknown({
server: { host: "localhost", port: 8080 },
replicas: [{ host: "r1" }, { host: "r2" }]
})
const program = Effect.gen(function* () {
const server = yield* Config.all({
host: Config.string("host"),
port: Config.port("port")
}).pipe(Config.nested("server"))
yield* Effect.log(`${server.host}:${server.port}`)
}).pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))

fromDotEnv reads and parses a .env file from disk. It needs a FileSystem in context (from your platform layer), and returns an Effect that produces the provider. fromDotEnvContents does the same from a string you already have.

import { ConfigProvider, Effect } from "effect"
const program = Effect.gen(function* () {
// Reads ".env" by default; pass { path } to override
const provider = yield* ConfigProvider.fromDotEnv()
return yield* myApp.pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider)
)
})
declare const myApp: Effect.Effect<void>

fromDir reads a directory tree where each file is a leaf value and each directory is a container. This matches how Kubernetes mounts ConfigMaps and Secrets — one file per key under a mount path.

import { ConfigProvider, Effect } from "effect"
const program = Effect.gen(function* () {
// /etc/myapp/database/host (file) → path ["database", "host"]
const provider = yield* ConfigProvider.fromDir({ rootPath: "/etc/myapp" })
return yield* myApp.pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider)
)
})
declare const myApp: Effect.Effect<void>

For application-wide setup, install the provider with a Layer instead of threading provideService through every call. ConfigProvider.layer replaces the active provider entirely.

import { ConfigProvider, Layer } from "effect"
// Use a fixed JSON object as the config source for the whole app
const ConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({ port: 8080 })
)
// Provide it alongside your other layers: Layer.provide(appLayer, ConfigLayer)

Use ConfigProvider.layerAdd when you want to augment the existing provider rather than replace it — for example, layering defaults underneath the real environment:

import { ConfigProvider } from "effect"
const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000" })
// The current env provider is tried first; `defaults` fills the gaps
const DefaultsLayer = ConfigProvider.layerAdd(defaults)
// Pass { asPrimary: true } to make `defaults` win and fall back to env instead

Providers are values you can combine and reshape.

Fallback with orElse — try one source, then another when a path is not found:

import { ConfigProvider } from "effect"
const env = ConfigProvider.fromEnv({ env: { HOST: "prod.example.com" } })
const file = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000" })
// env first; file provides anything env is missing (e.g. PORT)
const combined = ConfigProvider.orElse(env, file)

Re-casing keys with constantCase — bridge camelCase schema keys to SCREAMING_SNAKE_CASE env vars:

import { Config, ConfigProvider, Effect } from "effect"
const provider = ConfigProvider.fromEnv({
env: { DATABASE_HOST: "localhost" }
}).pipe(ConfigProvider.constantCase)
// The camelCase key now resolves to the DATABASE_HOST env var
const host = Config.string("databaseHost")
Effect.runFork(
host.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
)

ConfigProvider.constantCase is a preset of the more general ConfigProvider.mapInput, which lets you rewrite path segments however you like. ConfigProvider.nested(provider, prefix) prepends a prefix to every lookup, the provider-level counterpart to Config.nested.

Because the provider is just a service, tests provide a deterministic one — no need to mutate process.env.

import { Config, ConfigProvider, Effect } from "effect"
// The code under test reads its config the normal way
const loadConfig = Config.all({
host: Config.string("HOST"),
port: Config.port("PORT")
})
// In a test, swap in a fixed provider
const testProvider = ConfigProvider.fromUnknown({
HOST: "localhost",
PORT: "8080"
})
const result = loadConfig.pipe(
Effect.provideService(ConfigProvider.ConfigProvider, testProvider)
)
// result yields { host: "localhost", port: 8080 } deterministically

This is the idiomatic way to test configuration-dependent code. See Testing for the broader pattern of providing test layers.

ConfigProvider.make builds a provider from a lookup function. Return a Node for a found path, undefined for “not found”, and fail with SourceError only for genuine I/O errors.

import { ConfigProvider, Effect } from "effect"
// Back a provider with an arbitrary in-memory store
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
)
})

Use ConfigProvider.makeValue, makeRecord, and makeArray to describe leaves, objects, and arrays respectively. This is how you would back configuration with a database, a secrets manager, or a remote config service.