25 - Config: Type-Safe Configuration
Config is a typed description of how to read configuration values. Like Effect, a Config does nothing until it is interpreted by a ConfigProvider.
The default provider reads process.env; tests swap in a Map. Business logic does not change.
What you’ll work through
index.test.ts contains four beats: primitive readers (Config.string, Config.number, etc.), Config.redacted and Config.withDefault, swapping the default provider for a test Map, and exposing the resolved config as a service so business code yields it like any other dependency.
By the end you should treat config as a service like any other — the boundary between environment and code is one Layer.
The Boundary
reads fromConfig<A> ◄──────── ConfigProvider │ ├── process.env (production default) ├── ConfigProvider.fromUnknown(...) (tests) └── ConfigProvider.fromJson(...) (file-based)A Config<A> is interpreted at the boundary of the program. Within the program, every value is fully typed, no string | undefined, no manual coercion.
Config.string, integer, number, boolean
Creates a typed Config that reads a single environment variable. Parses the string into the target type and fails with a ConfigError if the value is missing or malformed.
import { Config } from "effect"
// ┌─── Config<string>// ▼const host = Config.string("HOST")
// ┌─── Config<number>// ▼const port = Config.int("PORT")
// ┌─── Config<boolean>// ▼const debug = Config.boolean("DEBUG")When to Use
For each environment variable that has a known type. Config.int accepts only valid integers; Config.boolean accepts "true", "false", "1", "0".
Config.redacted
Creates a Config<Redacted<string>> for secrets. The Redacted wrapper hides the value from logs, stack traces, and toString calls.
Extract the raw value with Redacted.value only at the point of use.
import { Config, Redacted } from "effect"
const program = Effect.gen(function* () { // ┌─── Redacted<string> // ▼ const apiKey = yield* Config.redacted("API_KEY")
// Logging this is safe, prints "<redacted>". console.log(apiKey)
// Extract only when calling the API. fetch(url, { headers: { Authorization: Redacted.value(apiKey) } })})Config.withDefault
Returns a Config that falls back to a constant when the underlying value is missing.
// ┌─── Config<number>// ▼const port = Config.int("PORT").pipe(Config.withDefault(3000))Details
Config.withDefault only fires when the value is absent. A present but malformed value (e.g. PORT=banana) still produces a ConfigError.
The signature widens the success type to A | A2, but TypeScript flattens it when the default matches.
When to Use
For optional settings. Prefer withDefault over Config.orElse(() => Config.succeed(...)) because it is the dedicated combinator for constant fallbacks.
Reach for Config.orElse only when the fallback is itself a Config (e.g. another environment variable).
Config.all
Reads multiple configs at once and assembles them into a record.
// ┌─── Config<{ host: string; port: number }>// ▼const server = Config.all({ host: Config.string("HOST"), port: Config.int("PORT").pipe(Config.withDefault(3000)),})Effect.provideService for Tests
Swap the default provider for a deterministic map. Nothing touches process.env.
import { ConfigProvider, Effect } from "effect"
const testProvider = ConfigProvider.fromUnknown( new Map([ ["PORT", "4000"], ["HOST", "localhost"], ]),)
const result = yield* program.pipe(Effect.provideService(testProvider))Config as a Service
Wrapping Config inside a Layer is the recommended production pattern. Business logic depends on the service tag, never on Config directly, so tests do not need to set up a ConfigProvider at all.
import { Context, Effect, Layer, Config, Redacted } from "effect"
class AppConfig extends Context.Service("AppConfig")< AppConfig, { readonly port: number; readonly apiKey: string }>() { // Reads from the ambient ConfigProvider (process.env by default). static readonly layer = Layer.effect( AppConfig, Effect.gen(function* () { const port = yield* Config.int("PORT").pipe(Config.withDefault(3000)) const apiKey = yield* Config.redacted("API_KEY") return { port, apiKey: Redacted.value(apiKey) } }), )
// Hardcoded values, no environment, no provider. static readonly testLayer = Layer.succeed(AppConfig, { port: 9999, apiKey: "test-key", })}
// Business logic depends on the tag.const program = Effect.gen(function* () { const config = yield* AppConfig return `Listening on ${config.port}`})Common Pitfalls
1, Reading Config inside business logic. Depend on a config service, not on Config.int("PORT"). The service is trivially swappable; the raw Config requires a ConfigProvider wired through every test.
2, Defaulting required secrets. Config.redacted("API_KEY") without a default is intentional: missing secrets should fail at startup, not silently fall back to "".
3, Calling Redacted.value too early. Extract at the point of use (the outbound HTTP call). Extracting at the config layer puts the raw secret on every layer-trace log.
4, Reaching for Config.orElse(() => Config.succeed(x)). For a constant fallback, Config.withDefault(x) is the dedicated combinator. Use orElse only when the fallback is itself a Config.
`Config.boolean`. - [ ] Wrap a secret with `Config.redacted` and provide a default with `Config.withDefault`. - [ ] Swap the default provider for a test `Map` and re-run the effect. - [ ] Expose the resolved config as a service so business code yields it like any other dependency.
See Also
- Previous,
50-ecommerce-checkout: parameter values flowing through the pipeline. - Next,
18-schema-modeling: validating data shape, not just type. - Foundations,
21-context-basics,22-layers,23-service-driven. - Later,
56-redacted: theRedactedtype in depth.
Run the tests: pnpm test 25-config
The full test suite
The index.test.ts for this lesson, verbatim from steps/25-config/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Config, ConfigProvider, Layer, Context, Redacted } from "effect"
const providerFromMap = (entries: Map<string, string>) => ConfigProvider.fromUnknown(Object.fromEntries(entries))
// =============================================================================// 23 - Config: Type-Safe Configuration// =============================================================================//// A Config<A> is a description of how to read a value. The ConfigProvider// interprets it. Default provider: process.env. Tests: providerFromMap.
describe("23 - Config", () => { // @beat // id: primitive-readers // shape: one-todo // title: Read typed primitives from the environment // teaches: Config.int, Config.string, and Config.boolean describe a typed read interpreted by a ConfigProvider. // hint: Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["KEY", "value"]]))) describe("primitive readers", () => { it("Config.int parses a numeric environment variable", async () => { // ┌─── Effect<number, ConfigError> // ▼ const program = Config.int("PORT")
const result = await Effect.runPromise( program.pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["PORT", "3000"]])), ), ), )
expect(result).toBe(3000) })
it("Config.string reads a string", async () => { const result = await Effect.runPromise( Config.string("HOST").pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["HOST", "localhost"]])), ), ), )
expect(result).toBe("localhost") })
it("Config.boolean parses true/false/1/0", async () => { const result = await Effect.runPromise( Config.boolean("DEBUG").pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["DEBUG", "true"]])), ), ), )
expect(result).toBe(true) }) }) // @end-beat
// @beat // id: redacted-and-default // shape: one-todo // title: Secrets and fallbacks // teaches: Config.redacted wraps secrets so logs cannot leak them; Config.withDefault supplies a constant fallback when absent. // hint: Redacted.value(secret) at the point of use; .pipe(Config.withDefault(8080)) for optional settings describe("Config.redacted", () => { it("hides the value from logs and toString", async () => { // ┌─── Redacted<string> // ▼ const program = Effect.gen(function* () { const secret = yield* Config.redacted("API_KEY") return Redacted.value(secret) })
const result = await Effect.runPromise( program.pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["API_KEY", "super-secret-123"]])), ), ), )
expect(result).toBe("super-secret-123") }) })
describe("Config.withDefault", () => { it("falls back when the variable is absent", async () => { const result = await Effect.runPromise( Config.int("MISSING_PORT") .pipe(Config.withDefault(8080)) .pipe(Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map()))), )
expect(result).toBe(8080) })
it("uses the variable when present", async () => { const result = await Effect.runPromise( Config.int("PORT") .pipe(Config.withDefault(8080)) .pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["PORT", "4000"]])), ), ), )
expect(result).toBe(4000) }) }) // @end-beat
// @beat // id: config-as-service // shape: multi-todo // title: Wrap Config in a service Layer // teaches: Business logic depends on a Context.Service — production reads via Layer.effect, tests use Layer.succeed with no provider. // hint: static readonly layer = Layer.effect(AppConfig, Effect.gen(function* () { ... })) describe("Config as a service", () => { class AppConfig extends Context.Service< AppConfig, { readonly port: number; readonly apiKey: string } >()("AppConfig") { static readonly layer = Layer.effect( AppConfig, Effect.gen(function* () { const port = yield* Config.int("PORT").pipe( Config.withDefault(3000), ) const apiKey = yield* Config.redacted("API_KEY") return { port, apiKey: Redacted.value(apiKey) } }), )
static readonly testLayer = Layer.succeed(AppConfig, { port: 9999, apiKey: "test-key", }) }
it("business logic depends on the service tag, not Config", async () => { const program = Effect.gen(function* () { const config = yield* AppConfig return `Server on ${config.port} with key ${config.apiKey}` })
const result = await Effect.runPromise( program.pipe(Effect.provide(AppConfig.testLayer)), )
expect(result).toBe("Server on 9999 with key test-key") })
it("production layer reads from the ambient provider", async () => { const program = Effect.gen(function* () { const config = yield* AppConfig return config.port })
const result = await Effect.runPromise( program.pipe( Effect.provide(AppConfig.layer), Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap( new Map([ ["PORT", "4000"], ["API_KEY", "prod-key"], ]), ), ), ), )
expect(result).toBe(4000) })
it("a missing required secret fails at startup", async () => { const program = Effect.gen(function* () { const config = yield* AppConfig return config.apiKey })
const exit = await Effect.runPromiseExit( program.pipe( Effect.provide(AppConfig.layer), Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([["PORT", "3000"]])), ), ), )
expect(exit._tag).toBe("Failure") }) }) // @end-beat
// @beat // id: test-provider // shape: one-todo // title: Isolate tests from process.env // teaches: providerFromMap supplies a deterministic source so tests never touch the real environment. // hint: Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap(new Map([...]))) describe("providerFromMap isolates tests from process.env", () => { it("reads multiple values from a deterministic source", async () => { const program = Effect.gen(function* () { const port = yield* Config.int("PORT") const host = yield* Config.string("HOST") return { port, host } })
const result = await Effect.runPromise( program.pipe( Effect.provideService(ConfigProvider.ConfigProvider, providerFromMap( new Map([ ["PORT", "8080"], ["HOST", "test-host"], ]), ), ), ), )
expect(result).toEqual({ port: 8080, host: "test-host" }) }) }) // @end-beat})