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.

A provider exposes a uniform Node interface that the Config module consumes:

  • Node — a discriminated union (Value | Record | Array) describing what lives at a path.
  • Path — an array of string | number segments addressing a node. String segments name object keys; numbers index into arrays.
  • load(path) — resolves a path to Effect<Node | undefined, SourceError>. undefined means not found; SourceError means the source itself failed.
ProviderReads 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

Backed by environment variables. Path segments are joined with _ for direct lookup, and env var names are also split on _ to discover nested keys — so DATABASE_HOST=localhost is reachable both at the flat path ["DATABASE_HOST"] and the nested path ["DATABASE", "HOST"]. Defaults to merging process.env and import.meta.env; pass { env } to override. Never fails with SourceError.

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}`)
// => 0:00:00.000 localhost:5432
}).pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))

Backed by any in-memory JavaScript value, typically a parsed JSON object. String segments index into object keys, numeric segments index into arrays, so it handles arbitrarily nested config naturally. Primitives (number, boolean, bigint) are stringified via String(...). Returns undefined for unresolved paths and never fails with SourceError.

import { Config, ConfigProvider, Effect } from "effect"
const provider = ConfigProvider.fromUnknown({
server: { host: "localhost", port: 8080 },
replicas: [{ host: "r1" }, { host: "r2" }]
})
const host = Config.string("host").parse(
provider.pipe(ConfigProvider.nested("server"))
)
// Effect.runSync(host) // => "localhost"
// numeric segments index into the array
const replica = Config.string("host").parse(
provider.pipe(ConfigProvider.nested(["replicas", 1]))
)
// Effect.runSync(replica) // => "r2"

Parses the string contents of a .env file into a provider (internally delegating to fromEnv). Supports export prefixes, single/double/backtick quoting, inline comments, and escaped newlines. Variable expansion (${VAR}) is disabled by default; enable with { expandVariables: true }.

import { Config, ConfigProvider } from "effect"
const provider = ConfigProvider.fromDotEnvContents(`
export HOST=localhost
PORT=3000
# this is a comment
GREETING="hello\\nworld"
`)
// Config.string("HOST").parse(provider) // => "localhost"
import { ConfigProvider } from "effect"
// expandVariables resolves ${...} references and ${VAR:-default} fallbacks
const provider = ConfigProvider.fromDotEnvContents(
`HOST=localhost\nURL=http://\${HOST}:3000`,
{ expandVariables: true }
)
// Config.string("URL").parse(provider) // => "http://localhost:3000"

The only option is expandVariables?: boolean.

Reads and parses a .env file from disk. Requires a FileSystem in context (from your platform layer) and returns an Effect that produces the provider. Defaults to reading ".env"; override with { path }. Fails with a PlatformError if the file cannot be read.

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

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. A file resolves to a Value with trimmed contents; a directory resolves to a Record of child names. Requires Path and FileSystem; defaults to root 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)
)
})
// program: Effect<void, never, Path.Path | FileSystem.FileSystem>
declare const myApp: Effect.Effect<void>

Builds a provider from a raw lookup function get: (path) => Effect<Node | undefined, SourceError>. Return a Node for a found path, undefined for not found, and fail with SourceError only for genuine I/O errors. The optional mapInput and prefix arguments are wired into the resulting load (combinators below use them).

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
)
})
// Config.string("host").parse(provider) // => "localhost"

Providers are values you can combine and reshape. Each combinator below supports both data-first (fn(self, ...)) and data-last (self.pipe(fn(...))) styles.

Returns a provider that falls back to that when self returns undefined for a path. The fallback only runs on not found — a SourceError from self propagates and is not caught.

import { Config, 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)
// Config.string("HOST").parse(combined) // => "prod.example.com"
// Config.string("PORT").parse(combined) // => "3000"

Rewrites path segments before they reach the underlying store. If the provider already has a mapInput, the functions compose — the existing mapping runs first, then f.

import { ConfigProvider } from "effect"
const provider = ConfigProvider.fromEnv({ env: { APP_HOST: "localhost" } })
// Uppercase string segments before lookup
const upper = ConfigProvider.mapInput(provider, (path) =>
path.map((seg) => (typeof seg === "string" ? seg.toUpperCase() : seg))
)
// path ["app_host"] now resolves to env var APP_HOST

A preset of mapInput that converts string path segments to CONSTANT_CASE (numeric segments are left unchanged). Bridges 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))
)
// => "localhost"

Prepends a prefix to every lookup, the provider-level counterpart to Config.nested. Accepts a single string or a full Path. The prefix is prepended after any mapInput transformation runs, so ordering matters when composing with mapInput / constantCase.

import { Config, 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")
// Config.string("HOST").parse(scoped) // => "localhost"

For application-wide setup, install the provider with a Layer instead of threading provideService through every call. Both layer functions accept either a plain ConfigProvider or an Effect<ConfigProvider> (handy for fromDotEnv / fromDir, which return Effects).

Replaces the active provider entirely for all downstream effects.

import { Config, ConfigProvider, Effect } from "effect"
// Use a fixed JSON object as the config source for the whole app
const ConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({ port: 8080 })
)
const program = Effect.gen(function* () {
return yield* Config.number("port")
})
// Effect.runSync(Effect.provide(program, ConfigLayer)) // => 8080

Augments the currently active provider rather than replacing it. By default the new provider is a fallback (consulted only when the current provider returns undefined); pass { asPrimary: true } to make the new provider win and fall back to the existing one.

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
const OverridesLayer = ConfigProvider.layerAdd(defaults, { asPrimary: true })

Inside a make lookup function you return Node values. Node is a discriminated union of Value | Record | Array; Record and Array can carry an optional co-located value (so the same path can be both a container and a leaf, mirroring an env var like A=x that also has children A_FOO).

Constructs a terminal string leaf node.

import { ConfigProvider } from "effect"
ConfigProvider.makeValue("3000")
// => { _tag: "Value", value: "3000" }

Constructs an object-like container with a known set of child keys, plus an optional co-located value.

import { ConfigProvider } from "effect"
ConfigProvider.makeRecord(new Set(["host", "port"]))
// => { _tag: "Record", keys: Set(["host", "port"]), value: undefined }
ConfigProvider.makeRecord(new Set(["FOO"]), "x")
// => { _tag: "Record", keys: Set(["FOO"]), value: "x" }

Constructs an indexed container with a known length, plus an optional co-located value.

import { ConfigProvider } from "effect"
ConfigProvider.makeArray(3)
// => { _tag: "Array", length: 3, value: undefined }

A fuller custom provider, choosing the right node per path:

import { ConfigProvider, Effect } from "effect"
const store: Record<string, unknown> = {
HOST: "localhost",
TAGS: ["a", "b", "c"]
}
const provider = ConfigProvider.make((path) => {
const value = store[String(path[0])]
if (value === undefined) return Effect.succeed(undefined) // not found
if (typeof value === "string") {
return Effect.succeed(ConfigProvider.makeValue(value))
}
if (Array.isArray(value)) {
return Effect.succeed(ConfigProvider.makeArray(value.length))
}
return Effect.fail(new ConfigProvider.SourceError({ message: "unsupported" }))
})

ConfigProvider.ConfigProvider (the interface and reference)

Section titled “ConfigProvider.ConfigProvider (the interface and reference)”

The ConfigProvider interface exposes load(path) (resolves mapInput and prefix transforms, then delegates to get), get(path) (raw store access without transforms), and the stored mapInput / prefix. The ConfigProvider.ConfigProvider value is the Context.Reference that holds the active provider, defaulting to fromEnv().

import { ConfigProvider, Effect } from "effect"
const program = Effect.gen(function* () {
// Retrieve the currently active provider
const provider = yield* ConfigProvider.ConfigProvider
return yield* provider.load(["HOST"])
// => { _tag: "Value", value: "localhost" } | undefined
}).pipe(
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromUnknown({ HOST: "localhost" })
)
)

An ordered sequence of string | number segments addressing a node. String segments name object keys; numeric segments index into arrays.

import type { ConfigProvider } from "effect"
const path: ConfigProvider.Path = ["database", "replicas", 0, "host"]

A Data.TaggedError ({ message: string; cause?: unknown }) signalling that a backing store could not be read (I/O failure, permission error, etc.). Do not use it for “key not found” — return undefined for that.

import { ConfigProvider, Effect } from "effect"
const failing = ConfigProvider.make(() =>
Effect.fail(new ConfigProvider.SourceError({ message: "connection refused" }))
)
// failing.load(["X"]) fails with SourceError; orElse will NOT swallow it

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.