1/**
2 * Data sources used by `Config` to load raw configuration values. A
3 * `ConfigProvider` reads paths from places such as environment variables,
4 * JavaScript objects, `.env` contents, or directories, and returns a uniform
5 * `Node` shape that config schemas can decode. The module also includes helpers
6 * for composing providers, changing paths, and installing providers through
7 * layers.
8 *
9 * @since 4.0.0
10 */
11
12import * as Arr from "./Array.ts"
13import * as Context from "./Context.ts"
14import * as Data from "./Data.ts"
15import * as Effect from "./Effect.ts"
16import * as FileSystem from "./FileSystem.ts"
17import { format } from "./Formatter.ts"
18import { dual } from "./Function.ts"
19import { PipeInspectableProto } from "./internal/core.ts"
20import * as Layer from "./Layer.ts"
21import * as Path_ from "./Path.ts"
22import type { Pipeable } from "./Pipeable.ts"
23import type { PlatformError } from "./PlatformError.ts"
24import * as Predicate from "./Predicate.ts"
25import type { Scope } from "./Scope.ts"
26import * as Str from "./String.ts"
27
28/**
29 * A discriminated union describing the shape of a configuration value at a
30 * given path.
31 *
32 * **When to use**
33 *
34 * Use when implementing a custom `ConfigProvider` by returning raw
35 * nodes from the `get` callback passed to {@link make}, or when inspecting raw
36 * provider output before schema parsing.
37 *
38 * **Details**
39 *
40 * `Value` is a terminal string leaf. `Record` is an object-like container
41 * whose immediate child keys are known and may carry an optional co-located
42 * `value`. `Array` is an indexed container with a known `length` and may also
43 * carry an optional co-located `value`.
44 *
45 * Provider lookups return `undefined` when no node exists at the requested
46 * path. Within a node that was found, `value: undefined` has a narrower
47 * structural meaning: the container exists but has no co-located scalar value.
48 *
49 * @see {@link makeValue} – construct a `Value` node
50 * @see {@link makeRecord} – construct a `Record` node
51 * @see {@link makeArray} – construct an `Array` node
52 *
53 * @category models
54 * @since 4.0.0
55 */
56export type Node =
57 /** A terminal string value */
58 | {
59 readonly _tag: "Value"
60 readonly value: string
61 }
62 /** An object; keys are unordered */
63 | {
64 readonly _tag: "Record"
65 readonly keys: ReadonlySet<string>
66 readonly value: string | undefined
67 }
68 /** An array-like container; length is the number of elements */
69 | {
70 readonly _tag: "Array"
71 readonly length: number
72 readonly value: string | undefined
73 }
74
75/**
76 * Creates a `Value` node representing a terminal string leaf.
77 *
78 * **When to use**
79 *
80 * Use when building nodes inside a custom `ConfigProvider`'s `get`
81 * callback.
82 *
83 * **Details**
84 *
85 * The function returns a new plain object.
86 *
87 * **Example** (Creating a value node)
88 *
89 * ```ts import.meta.vitest
90 * import { ConfigProvider } from "effect"
91 *
92 * ConfigProvider.makeValue("3000") // => { _tag: "Value", value: "3000" }
93 * ```
94 *
95 * @see {@link makeRecord} – for object-like containers
96 * @see {@link makeArray} – for array-like containers
97 *
98 * @category constructors
99 * @since 4.0.0
100 */
101export function makeValue(value: string): Node {
102 return { _tag: "Value", value }
103}
104
105/**
106 * Creates a `Record` node representing an object-like container with known
107 * child keys.
108 *
109 * **When to use**
110 *
111 * Use when you need to describe a directory or JSON object inside a custom
112 * provider.
113 *
114 * **Details**
115 *
116 * The optional `value` allows a node to be both a container and a leaf at the
117 * same time (for example, an env var `A=x` that also has children `A_FOO` and
118 * `A_BAR`).
119 *
120 * **Example** (Creating a record node)
121 *
122 * ```ts import.meta.vitest
123 * import { ConfigProvider } from "effect"
124 *
125 * const node = ConfigProvider.makeRecord(new Set(["host", "port"]))
126 * node._tag // => "Record"
127 * if (node._tag === "Record") {
128 * node.keys // => new Set(["host", "port"])
129 * node.value // => undefined
130 * }
131 * ```
132 *
133 * @see {@link makeValue} – for terminal leaves
134 * @see {@link makeArray} – for array-like containers
135 *
136 * @category constructors
137 * @since 4.0.0
138 */
139export function makeRecord(keys: ReadonlySet<string>, value?: string): Node {
140 return { _tag: "Record", keys, value }
141}
142
143/**
144 * Creates an `Array` node representing an indexed container with a known
145 * length.
146 *
147 * **When to use**
148 *
149 * Use when you need to describe a JSON array or numerically indexed env vars
150 * inside a custom provider.
151 *
152 * **Details**
153 *
154 * The optional `value` allows a node to be both a container and a leaf at the
155 * same time.
156 *
157 * **Example** (Creating an array node)
158 *
159 * ```ts import.meta.vitest
160 * import { ConfigProvider } from "effect"
161 *
162 * ConfigProvider.makeArray(3) // => { _tag: "Array", length: 3, value: undefined }
163 * ```
164 *
165 * @see {@link makeValue} – for terminal leaves
166 * @see {@link makeRecord} – for object-like containers
167 *
168 * @category constructors
169 * @since 4.0.0
170 */
171export function makeArray(length: number, value?: string): Node {
172 return { _tag: "Array", length, value }
173}
174
175/**
176 * Typed error indicating that a configuration source could not be read.
177 *
178 * **When to use**
179 *
180 * Use when you need to report that a custom provider's underlying store is
181 * unreachable or produced an I/O error while reading configuration data.
182 *
183 * **Gotchas**
184 *
185 * Do not use `SourceError` for "key not found". That case is represented by
186 * returning `undefined` from `load`.
187 *
188 * **Example** (Failing with a SourceError)
189 *
190 * ```ts import.meta.vitest
191 * import { ConfigProvider, Effect } from "effect"
192 *
193 * const provider = ConfigProvider.make((_path) =>
194 * Effect.fail(
195 * new ConfigProvider.SourceError({ message: "connection refused" })
196 * )
197 * )
198 *
199 * Effect.runSync(Effect.flip(provider.load(["host"]))).message // => "connection refused"
200 * ```
201 *
202 * @see {@link ConfigProvider} – the interface whose `load` may fail with this
203 * error
204 *
205 * @category errors
206 * @since 4.0.0
207 */
208export class SourceError extends Data.TaggedError("SourceError")<{
209 readonly message: string
210 readonly cause?: unknown
211}> {}
212
213/**
214 * An ordered sequence of string or numeric segments that addresses a node in
215 * the configuration tree. String segments name object keys; numeric segments
216 * index into arrays.
217 *
218 * **When to use**
219 *
220 * Use to address raw configuration nodes when implementing or transforming a
221 * `ConfigProvider`.
222 *
223 * **Example** (A typical config path)
224 *
225 * ```ts import.meta.vitest
226 * import type { ConfigProvider } from "effect"
227 *
228 * const path: ConfigProvider.Path = ["database", "replicas", 0, "host"]
229 * path.join(".") // => "database.replicas.0.host"
230 * ```
231 *
232 * @category models
233 * @since 4.0.0
234 */
235export type Path = ReadonlyArray<string | number>
236
237/**
238 * The core interface for loading raw configuration data.
239 *
240 * **When to use**
241 *
242 * Use to type-annotate variables that hold a provider or to implement a
243 * custom provider via {@link make}.
244 *
245 * **Details**
246 *
247 * `load(path)` is the semantic lookup operation used by the `Config` module.
248 * It applies provider transformations and composition before consulting the
249 * underlying source. `undefined` means "not found", a `Node` means the path
250 * exists, and `SourceError` means the source itself failed.
251 *
252 * `mapInput(f)` is the provider's path-transformation capability. Keeping this
253 * capability on the provider allows source and composite providers to preserve
254 * their own lookup behavior without exposing an internal representation.
255 * Transformations compose in application order: `f` receives the path produced
256 * by earlier transformations.
257 *
258 * `load` deliberately accepts only a `Path`. Path transformation is modeled by
259 * returning another provider through `mapInput`, rather than by adding a
260 * transformation callback to every lookup. Custom implementations therefore
261 * expose lookup and transformation behavior, but no source or composition
262 * state.
263 *
264 * @see {@link make} – construct a provider from a lookup function
265 * @see {@link orElse} – compose providers with fallback
266 *
267 * @category services
268 * @since 4.0.0
269 */
270export interface ConfigProvider extends Pipeable {
271 /**
272 * Returns a `Node` when `path` exists or `undefined` when it does not. Fails
273 * with `SourceError` when the underlying source cannot be read.
274 *
275 * **When to use**
276 *
277 * Use to resolve a path through this provider's path transformations before
278 * reading the backing source.
279 *
280 * **Details**
281 *
282 * Lookup absence controls provider composition, such as whether
283 * {@link orElse} consults its fallback. An optional `value` inside a found
284 * `Record` or `Array` node remains `undefined` because it describes the shape
285 * of that node rather than the outcome of the lookup.
286 */
287 readonly load: (path: Path) => Effect.Effect<Node | undefined, SourceError>
288
289 /**
290 * Returns a provider that applies `f` to lookup paths after any existing path
291 * transformations.
292 *
293 * **When to use**
294 *
295 * Use to implement provider-specific path transformation behavior. Most
296 * callers should use the pipeable {@link mapInput} combinator.
297 *
298 * **Details**
299 *
300 * This capability is part of the provider interface so composite providers
301 * can distribute transformations to their operands while preserving each
302 * operand's behavior. Providers created with {@link make} implement it
303 * automatically.
304 */
305 readonly mapInput: (f: (path: Path) => Path) => ConfigProvider
306}
307
308/**
309 * Context reference for the active raw configuration provider, registered in the context with a
310 * default value of `fromEnv()`. Because it is a `Context.Reference`, it is
311 * available without explicit provision; `Config` schemas automatically resolve
312 * it.
313 *
314 * **When to use**
315 *
316 * Use to override the active raw configuration provider for an entire program,
317 * or retrieve the current provider inside an Effect.
318 *
319 * **Example** (Providing a custom provider)
320 *
321 * ```ts import.meta.vitest
322 * import { ConfigProvider, Effect } from "effect"
323 *
324 * const provider = ConfigProvider.fromUnknown({ port: 8080 })
325 *
326 * const program = Effect.gen(function*() {
327 * const current = yield* ConfigProvider.ConfigProvider
328 * return current
329 * }).pipe(
330 * Effect.provideService(ConfigProvider.ConfigProvider, provider)
331 * )
332 *
333 * Effect.runSync(program) === provider // => true
334 * ```
335 *
336 * @see {@link layer} – install a provider as a Layer
337 * @see {@link layerAdd} – add a fallback provider as a Layer
338 *
339 * @category services
340 * @since 4.0.0
341 */
342export const ConfigProvider: Context.Reference<ConfigProvider> = Context.Reference<ConfigProvider>(
343 "effect/ConfigProvider",
344 { defaultValue: () => fromEnv() }
345)
346
347const Proto = {
348 ...PipeInspectableProto,
349 toJSON(this: ConfigProvider) {
350 return {
351 _id: "ConfigProvider"
352 }
353 }
354}
355
356const identityPath = (path: Path): Path => path
357
358function makeProvider(
359 load: (path: Path) => Effect.Effect<Node | undefined, SourceError>,
360 mapInput: (f: (path: Path) => Path) => ConfigProvider
361): ConfigProvider {
362 const self = Object.create(Proto)
363 self.load = load
364 self.mapInput = mapInput
365 return self
366}
367
368function makeSource(
369 get: (path: Path) => Effect.Effect<Node | undefined, SourceError>,
370 transform: (path: Path) => Path
371): ConfigProvider {
372 return makeProvider(
373 (path) => get(transform(path)),
374 (f) => makeSource(get, (path) => f(transform(path)))
375 )
376}
377
378function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvider {
379 return makeProvider(
380 (path) =>
381 Effect.flatMap(
382 first.load(path),
383 (node) => node !== undefined ? Effect.succeed(node) : second.load(path)
384 ),
385 (f) => makeOrElse(first.mapInput(f), second.mapInput(f))
386 )
387}
388
389/**
390 * Creates a `ConfigProvider` from a raw lookup function.
391 *
392 * **When to use**
393 *
394 * Use when implementing a provider backed by a custom store, such as a
395 * database, remote API, or in-memory map.
396 *
397 * **Details**
398 *
399 * The `get` callback receives a `Path` and must return
400 * `Effect<Node | undefined, SourceError>`. Return `undefined` when the path does
401 * not exist, a `Node` when it does, and fail with `SourceError` only when the
402 * source cannot be read.
403 *
404 * Providers created by `make` also implement the path-transformation
405 * capability used by {@link mapInput}, {@link constantCase}, and
406 * {@link nested}.
407 *
408 * **Example** (Creating a simple in-memory provider)
409 *
410 * ```ts import.meta.vitest
411 * import { ConfigProvider, Effect } from "effect"
412 *
413 * const data: Record<string, string> = {
414 * host: "localhost",
415 * port: "5432"
416 * }
417 *
418 * const provider = ConfigProvider.make((path) => {
419 * const key = path.join(".")
420 * const value = data[key]
421 * return Effect.succeed(
422 * value !== undefined ? ConfigProvider.makeValue(value) : undefined
423 * )
424 * })
425 *
426 * Effect.runSync(provider.load(["host"])) // => ConfigProvider.makeValue("localhost")
427 * ```
428 *
429 * @see {@link fromEnv} – pre-built provider for environment variables
430 * @see {@link fromUnknown} – pre-built provider for JSON objects
431 *
432 * @category constructors
433 * @since 4.0.0
434 */
435export function make(get: (path: Path) => Effect.Effect<Node | undefined, SourceError>): ConfigProvider {
436 return makeSource(get, identityPath)
437}
438
439/**
440 * Returns a provider that falls back to `that` when `self` returns `undefined`
441 * for a path.
442 *
443 * **When to use**
444 *
445 * Use to layer multiple config sources, such as env vars plus a defaults file,
446 * or provide partial overrides on top of a base config.
447 *
448 * **Details**
449 *
450 * Each provider keeps its own path transformations. If the combined provider
451 * is later transformed with {@link mapInput} or {@link nested}, the
452 * transformation is applied to both sides.
453 *
454 * **Gotchas**
455 *
456 * The fallback only runs when the path is not found (`undefined`). A
457 * `SourceError` from `self` is not caught; it propagates immediately.
458 *
459 * **Example** (Falling back to a default provider)
460 *
461 * ```ts import.meta.vitest
462 * import { ConfigProvider, Effect } from "effect"
463 *
464 * const envProvider = ConfigProvider.fromEnv({
465 * env: { HOST: "prod.example.com" }
466 * })
467 * const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: "3000" })
468 *
469 * const combined = ConfigProvider.orElse(envProvider, defaults)
470 *
471 * const host = Effect.runSync(combined.load(["HOST"]))
472 * const port = Effect.runSync(combined.load(["PORT"]))
473 * const values = [host?.value, port?.value] // => ["prod.example.com", "3000"]
474 * ```
475 *
476 * @see {@link layerAdd} – install a fallback provider via a Layer
477 *
478 * @category combinators
479 * @since 2.0.0
480 */
481export const orElse: {
482 (that: ConfigProvider): (self: ConfigProvider) => ConfigProvider
483 (self: ConfigProvider, that: ConfigProvider): ConfigProvider
484} = dual(
485 2,
486 (self: ConfigProvider, that: ConfigProvider): ConfigProvider => makeOrElse(self, that)
487)
488
489/**
490 * Transforms the path segments before they reach the underlying store.
491 *
492 * **When to use**
493 *
494 * Use when you need to rename, re-case, or otherwise transform config path
495 * segments before lookup.
496 *
497 * **Details**
498 *
499 * The function `f` receives the whole path produced by earlier provider
500 * transformations and must return a new path. Lookup path transformations
501 * compose in application order: the existing transformation runs first, then
502 * `f` runs. For providers composed with {@link orElse}, the transformation is
503 * applied to each operand.
504 *
505 * The combinator delegates transformation to the provider itself. Use
506 * {@link make} for custom sources so this capability is implemented
507 * automatically.
508 *
509 * **Example** (Uppercasing path segments)
510 *
511 * ```ts import.meta.vitest
512 * import { ConfigProvider, Effect } from "effect"
513 *
514 * const provider = ConfigProvider.fromEnv({
515 * env: { APP_HOST: "localhost" }
516 * })
517 *
518 * const upper = ConfigProvider.mapInput(provider, (path) =>
519 * path.map((seg) =>
520 * typeof seg === "string" ? seg.toUpperCase() : seg
521 * )
522 * )
523 *
524 * const node = Effect.runSync(upper.load(["app_host"]))
525 * node?.value // => "localhost"
526 * ```
527 *
528 * @see {@link constantCase} – a preset that converts to `CONSTANT_CASE`
529 * @see {@link nested} – for prepending a prefix instead of transforming
530 *
531 * @category combinators
532 * @since 4.0.0
533 */
534export const mapInput: {
535 (f: (path: Path) => Path): (self: ConfigProvider) => ConfigProvider
536 (self: ConfigProvider, f: (path: Path) => Path): ConfigProvider
537} = dual(
538 2,
539 (self: ConfigProvider, f: (path: Path) => Path): ConfigProvider => self.mapInput(f)
540)
541
542/**
543 * Converts all string path segments to `CONSTANT_CASE` before lookup.
544 *
545 * **When to use**
546 *
547 * Use to bridge camelCase schema keys to `SCREAMING_SNAKE_CASE`
548 * environment variables.
549 *
550 * **Details**
551 *
552 * Numeric segments are left unchanged. String segments use `String.configCase`
553 * so numeric word groups such as `v2` are preserved for environment variable
554 * names. This is a specialization of {@link mapInput}.
555 *
556 * **Example** (Resolving camelCase keys to env vars)
557 *
558 * ```ts import.meta.vitest
559 * import { ConfigProvider, Effect } from "effect"
560 *
561 * const provider = ConfigProvider.fromEnv({
562 * env: { DATABASE_HOST: "localhost" }
563 * }).pipe(ConfigProvider.constantCase)
564 *
565 * // path ["databaseHost"] now resolves to env var DATABASE_HOST
566 * const node = Effect.runSync(provider.load(["databaseHost"]))
567 * node?.value // => "localhost"
568 * ```
569 *
570 * @see {@link mapInput} – for arbitrary path transformations
571 *
572 * @category combinators
573 * @since 2.0.0
574 */
575export const constantCase: (self: ConfigProvider) => ConfigProvider = mapInput((path) =>
576 path.map((seg) => typeof seg === "number" ? seg : Str.configCase(seg))
577)
578
579/**
580 * Scopes a provider so that all lookups are prefixed with the given path
581 * segments.
582 *
583 * **When to use**
584 *
585 * Use to namespace config under a prefix like `"app"` or `"database"`, or
586 * reuse the same provider shape for multiple sub-configs.
587 *
588 * **Details**
589 *
590 * Accepts a single string or a full `Path` array. For providers composed with
591 * {@link orElse}, the prefix is applied to each operand. Supports both
592 * data-last and data-first calling conventions.
593 *
594 * **Gotchas**
595 *
596 * Ordering matters when composing with {@link mapInput} or
597 * {@link constantCase}. Later provider transformations run after earlier ones:
598 * a later `nested` becomes the outer prefix, and a later `mapInput` sees the
599 * whole path produced by previous transformations.
600 *
601 * **Example** (Nesting under a prefix)
602 *
603 * ```ts import.meta.vitest
604 * import { ConfigProvider, Effect } from "effect"
605 *
606 * const provider = ConfigProvider.fromEnv({
607 * env: { APP_HOST: "localhost", APP_PORT: "3000" }
608 * })
609 *
610 * // Lookups for ["HOST"] now resolve to ["APP", "HOST"]
611 * const scoped = ConfigProvider.nested(provider, "APP")
612 * const node = Effect.runSync(scoped.load(["HOST"]))
613 * node?.value // => "localhost"
614 * ```
615 *
616 * @see {@link mapInput} – for arbitrary path transformations
617 *
618 * @category combinators
619 * @since 2.0.0
620 */
621export const nested: {
622 (prefix: string | Path): (self: ConfigProvider) => ConfigProvider
623 (self: ConfigProvider, prefix: string | Path): ConfigProvider
624} = dual(
625 2,
626 (self: ConfigProvider, prefix: string | Path): ConfigProvider => {
627 const path = typeof prefix === "string" ? [prefix] : prefix
628 return mapInput(self, (input) => [...path, ...input])
629 }
630)
631
632/**
633 * Provides a layer that installs a `ConfigProvider` as the active provider for
634 * all downstream effects, replacing any previously installed provider.
635 *
636 * **When to use**
637 *
638 * Use to set the config source for an entire application or test suite.
639 *
640 * **Details**
641 *
642 * Accepts either a plain `ConfigProvider` or an `Effect` that produces one.
643 * When given an Effect, it is evaluated once when the layer is built.
644 *
645 * **Example** (Reading config from a JSON object)
646 *
647 * ```ts import.meta.vitest
648 * import { Config, ConfigProvider, Effect, Layer } from "effect"
649 *
650 * const TestLayer = ConfigProvider.layer(
651 * ConfigProvider.fromUnknown({ port: 8080 })
652 * )
653 *
654 * const program = Effect.gen(function*() {
655 * const port = yield* Config.Number("port")
656 * return port
657 * })
658 *
659 * Effect.runSync(Effect.provide(program, TestLayer)) // => 8080
660 * ```
661 *
662 * @see {@link layerAdd} – add a provider without replacing the existing one
663 *
664 * @category layers
665 * @since 4.0.0
666 */
667export const layer = <E = never, R = never>(
668 self: ConfigProvider | Effect.Effect<ConfigProvider, E, R>
669): Layer.Layer<never, E, Exclude<R, Scope>> =>
670 Effect.isEffect(self) ? Layer.effect(ConfigProvider)(self) : Layer.succeed(ConfigProvider)(self)
671
672/**
673 * Creates a Layer that composes a new `ConfigProvider` with the currently
674 * active one, rather than replacing it.
675 *
676 * **When to use**
677 *
678 * Use to add defaults that should only apply when the primary provider has no
679 * value for a path, or override specific keys while keeping the rest from the
680 * existing provider by setting `asPrimary: true`.
681 *
682 * **Details**
683 *
684 * By default, the new provider acts as a fallback and is consulted only when
685 * the current provider returns `undefined`. Set `asPrimary: true` to make
686 * the new provider the primary source, with the existing one as fallback.
687 *
688 * **Example** (Adding default values)
689 *
690 * ```ts import.meta.vitest
691 * import { Config, ConfigProvider, Effect, Layer } from "effect"
692 *
693 * const defaults = ConfigProvider.fromUnknown({
694 * HOST: "localhost",
695 * PORT: "3000"
696 * })
697 *
698 * // The current env provider is tried first; `defaults` is the fallback
699 * const DefaultsLayer = ConfigProvider.layerAdd(defaults)
700 * const BaseLayer = ConfigProvider.layer(ConfigProvider.fromUnknown({}))
701 * const program = Config.String("HOST")
702 *
703 * const layer = Layer.provide(DefaultsLayer, BaseLayer)
704 * Effect.runSync(Effect.provide(program, layer)) // => "localhost"
705 * ```
706 *
707 * @see {@link layer} – replace the provider entirely
708 * @see {@link orElse} – compose providers without layers
709 *
710 * @category layers
711 * @since 4.0.0
712 */
713export const layerAdd = <E = never, R = never>(
714 self: ConfigProvider | Effect.Effect<ConfigProvider, E, R>,
715 options?: {
716 readonly asPrimary?: boolean | undefined
717 } | undefined
718): Layer.Layer<never, E, Exclude<R, Scope>> =>
719 Layer.effect(ConfigProvider)(
720 Effect.gen(function*() {
721 const current = yield* ConfigProvider
722 const configProvider = Effect.isEffect(self) ? yield* self : self
723 return options?.asPrimary ? orElse(configProvider, current) : orElse(current, configProvider)
724 })
725 )
726
727/**
728 * Creates a `ConfigProvider` backed by an in-memory JavaScript value
729 * (typically a parsed JSON object).
730 *
731 * **When to use**
732 *
733 * Use when you need deterministic config from an in-memory JavaScript value,
734 * such as in tests, embedded config, or parsed JSON.
735 *
736 * **Details**
737 *
738 * Path traversal follows standard JS rules: string segments index into object
739 * keys, numeric segments index into arrays. Returns `undefined` for any
740 * path that cannot be resolved. Never fails with `SourceError`.
741 *
742 * Primitive values (`number`, `boolean`, `bigint`) are stringified via
743 * `String(...)`.
744 *
745 * Literal empty strings are treated as missing values when loaded as values by
746 * default. Pass `{ preserveEmptyStrings: true }` to keep empty strings as
747 * explicit values.
748 *
749 * **Gotchas**
750 *
751 * Object keys and array lengths reflect the original input shape. A leaf value
752 * of `""` is treated as missing when that leaf is loaded, but the parent
753 * container still reports its original keys or length.
754 *
755 * **Example** (Providing config from a plain object)
756 *
757 * ```ts import.meta.vitest
758 * import { Config, ConfigProvider, Effect } from "effect"
759 *
760 * const provider = ConfigProvider.fromUnknown({
761 * database: {
762 * host: "localhost",
763 * port: 5432
764 * }
765 * })
766 *
767 * const host = Config.String("host").parse(
768 * provider.pipe(ConfigProvider.nested("database"))
769 * )
770 *
771 * Effect.runSync(host) // => "localhost"
772 * ```
773 *
774 * @see {@link fromEnv} – for environment variables
775 * @see {@link make} – for custom backing stores
776 *
777 * @category constructors
778 * @since 4.0.0
779 */
780export function fromUnknown(root: unknown, options?: {
781 readonly preserveEmptyStrings?: boolean | undefined
782}): ConfigProvider {
783 const preserveEmptyStrings = options?.preserveEmptyStrings === true
784 return make((path) => Effect.succeed(nodeAtJson(root, path, preserveEmptyStrings)))
785}
786
787function nodeAtJson(root: unknown, path: Path, preserveEmptyStrings: boolean): Node | undefined {
788 let cur: unknown = root
789
790 for (const seg of path) {
791 if (cur === null || cur === undefined) return undefined
792
793 if (Array.isArray(cur)) {
794 if (typeof seg !== "number" || !Number.isInteger(seg) || seg < 0 || seg >= cur.length) return undefined
795 cur = cur[seg]
796 continue
797 }
798
799 if (Predicate.isObject(cur)) {
800 if (typeof seg !== "string") return undefined
801 if (!Object.hasOwn(cur, seg)) return undefined
802 cur = cur[seg]
803 continue
804 }
805
806 // cannot descend
807 return undefined
808 }
809
810 return describeUnknown(cur, preserveEmptyStrings)
811}
812
813function describeUnknown(u: unknown, preserveEmptyStrings: boolean): Node | undefined {
814 if (u === undefined || u === null) return undefined
815 if (typeof u === "string") return stringNode(u, preserveEmptyStrings)
816 if (typeof u === "number" || typeof u === "boolean" || typeof u === "bigint") {
817 return makeValue(String(u))
818 }
819 if (Array.isArray(u)) return makeArray(u.length)
820 if (Predicate.isObject(u)) {
821 return makeRecord(new Set(Object.keys(u)))
822 }
823 // unknown values
824 return makeValue(format(u))
825}
826
827function stringNode(value: string, preserveEmptyStrings: boolean): Node | undefined {
828 const normalized = emptyStringAsMissing(value, preserveEmptyStrings)
829 return normalized === undefined ? undefined : makeValue(normalized)
830}
831
832function emptyStringAsMissing(value: string | undefined, preserveEmptyStrings: boolean): string | undefined {
833 return value === "" && !preserveEmptyStrings ? undefined : value
834}
835
836/**
837 * Creates a `ConfigProvider` backed by an explicit environment record.
838 *
839 * **When to use**
840 *
841 * Use when a restricted runtime cannot evaluate the automatic environment
842 * detection performed by {@link fromEnv}, or whenever the environment record
843 * must be supplied explicitly.
844 *
845 * **Details**
846 *
847 * `undefined` values are ignored. Path lookup and child discovery otherwise
848 * use the same environment-variable semantics as {@link fromEnv}.
849 *
850 * Environment variable names are captured at construction time to establish
851 * record keys and array lengths. The supplied record remains live for value
852 * lookups, so updates to known paths are observed by later loads. Keys added
853 * after construction can be loaded directly, but do not appear in captured
854 * parent record keys or array lengths.
855 *
856 * Literal empty strings are treated as missing values by default. Pass
857 * `{ preserveEmptyStrings: true }` to keep empty strings as explicit values.
858 *
859 * @see {@link fromEnv} – automatically reads the runtime environment
860 *
861 * @category constructors
862 * @since 4.0.0
863 */
864export function fromEnvRecord(
865 env: Record<string, string | undefined>,
866 options?: { readonly preserveEmptyStrings?: boolean | undefined }
867): ConfigProvider {
868 const preserveEmptyStrings = options?.preserveEmptyStrings === true
869 const trie = buildEnvTrie(env)
870 return make((path) => Effect.succeed(nodeAtEnv(trie, env, path, preserveEmptyStrings)))
871}
872
873/**
874 * Creates a `ConfigProvider` backed by environment variables.
875 *
876 * **When to use**
877 *
878 * Use to read configuration from `process.env`, which is the default when no
879 * provider is explicitly set, or pass a custom env record for testing.
880 *
881 * **Details**
882 *
883 * Path segments are joined with `_` for direct lookup, and env var names are
884 * also split on `_` to build a trie for child key discovery. This means
885 * `DATABASE_HOST=localhost` is accessible at both path `["DATABASE_HOST"]`
886 * and `["DATABASE", "HOST"]`. If all immediate children of a trie node have
887 * purely numeric names, the node is reported as an `Array`; otherwise as a
888 * `Record`.
889 *
890 * The default environment merges `process.env` and `import.meta.env` (when
891 * available). Override by passing `{ env: { ... } }`.
892 *
893 * Literal empty strings are treated as missing values when loaded as values by
894 * default. Pass `{ preserveEmptyStrings: true }` to keep empty strings as
895 * explicit values. Child discovery still reflects the environment variable
896 * names present in the source.
897 *
898 * Never fails with `SourceError` — all lookups are synchronous.
899 *
900 * **Example** (Reading from a custom env record)
901 *
902 * ```ts import.meta.vitest
903 * import { Config, ConfigProvider, Effect } from "effect"
904 *
905 * const provider = ConfigProvider.fromEnv({
906 * env: {
907 * DATABASE_HOST: "localhost",
908 * DATABASE_PORT: "5432"
909 * }
910 * })
911 *
912 * const host = Config.String("HOST").parse(
913 * provider.pipe(ConfigProvider.nested("DATABASE"))
914 * )
915 *
916 * Effect.runSync(host) // => "localhost"
917 * ```
918 *
919 * @see {@link fromUnknown} – for JSON objects
920 * @see {@link fromEnvRecord} – for explicit records in restricted runtimes
921 * @see {@link constantCase} – bridge camelCase keys to SCREAMING_SNAKE_CASE
922 *
923 * @category constructors
924 * @since 2.0.0
925 */
926export function fromEnv(options?: {
927 readonly env?: Record<string, string> | undefined
928 readonly preserveEmptyStrings?: boolean | undefined
929}): ConfigProvider {
930 const env: Record<string, string | undefined> = options?.env ?? {
931 ...(globalThis as {
932 readonly process?: { readonly env?: Record<string, string | undefined> }
933 }).process?.env,
934 ...(import.meta as any)?.env
935 }
936 return fromEnvRecord(env, { preserveEmptyStrings: options?.preserveEmptyStrings })
937}
938
939type EnvTrieNode = {
940 children?: Record<string, EnvTrieNode>
941}
942
943function buildEnvTrie(env: Record<string, string | undefined>): EnvTrieNode {
944 const trie: EnvTrieNode = {}
945
946 for (const [name, value] of Object.entries(env)) {
947 if (value === undefined) continue
948
949 // Split on "_" and keep empty segments (no special handling for "__")
950 const segments = name.split("_")
951
952 let node = trie
953 for (const seg of segments) {
954 const children = node.children ??= Object.create(null)
955 node = children[seg] ??= {}
956 }
957 }
958
959 return trie
960}
961
962function nodeAtEnv(
963 trie: EnvTrieNode,
964 env: Record<string, string | undefined>,
965 path: Path,
966 preserveEmptyStrings: boolean
967): Node | undefined {
968 const key = path.map(String).join("_")
969 const leafValue = emptyStringAsMissing(Object.hasOwn(env, key) ? env[key] : undefined, preserveEmptyStrings)
970
971 const trieNode = trieNodeAt(trie, path)
972 const children = trieNode?.children ? Object.keys(trieNode.children) : []
973
974 if (children.length === 0) {
975 return leafValue === undefined ? undefined : makeValue(leafValue)
976 }
977
978 const allNumeric = children.every(Arr.isCanonicalArrayIndex)
979 if (allNumeric) {
980 const length = Math.max(...children.map((k) => parseInt(k, 10))) + 1
981 return makeArray(length, leafValue)
982 }
983
984 return makeRecord(new Set(children), leafValue)
985}
986
987function trieNodeAt(root: EnvTrieNode, path: Path): EnvTrieNode | undefined {
988 if (path.length === 0) return root
989
990 // Convert path segments to strings and navigate through the trie
991 let node: EnvTrieNode | undefined = root
992 for (const seg of path) {
993 node = node?.children?.[String(seg)]
994 if (!node) return undefined
995 }
996 return node
997}
998
999/**
1000 * Creates a `ConfigProvider` by parsing the string contents of a `.env` file.
1001 *
1002 * **When to use**
1003 *
1004 * Use when you already have the `.env` contents as a string, such as contents
1005 * fetched from a remote store or embedded in a test.
1006 *
1007 * **Details**
1008 *
1009 * Supports `export` prefixes, single/double/backtick quoting, inline comments,
1010 * and escaped newlines. Variable expansion (for example, `${VAR}`) is disabled
1011 * by default; enable with `{ expandVariables: true }`.
1012 *
1013 * Literal empty strings are treated as missing values when loaded as values by
1014 * default. Pass `{ preserveEmptyStrings: true }` to keep empty strings as
1015 * explicit values. Child discovery still reflects the keys present in the
1016 * parsed `.env` source.
1017 *
1018 * Parsing is based on the `dotenv` / `dotenv-expand` algorithm.
1019 *
1020 * Internally delegates to {@link fromEnvRecord} with the parsed key-value pairs.
1021 *
1022 * **Example** (Parsing .env contents)
1023 *
1024 * ```ts import.meta.vitest
1025 * import { ConfigProvider, Effect } from "effect"
1026 *
1027 * const contents = `
1028 * HOST=localhost
1029 * PORT=3000
1030 * # this is a comment
1031 * `
1032 *
1033 * const provider = ConfigProvider.fromDotEnvContents(contents)
1034 * const port = Effect.runSync(provider.load(["PORT"]))
1035 * port?.value // => "3000"
1036 * ```
1037 *
1038 * @see {@link fromDotEnv} – loads a `.env` file from disk
1039 * @see {@link fromEnvRecord} – for explicit environment records
1040 * @see {@link fromEnv} – for raw environment variable access
1041 *
1042 * @category constructors
1043 * @since 4.0.0
1044 */
1045export function fromDotEnvContents(lines: string, options?: {
1046 readonly expandVariables?: boolean | undefined
1047 readonly preserveEmptyStrings?: boolean | undefined
1048}): ConfigProvider {
1049 let env = parseDotEnvContents(lines)
1050 if (options?.expandVariables) {
1051 env = dotEnvExpand(env)
1052 }
1053 return fromEnvRecord(env, { preserveEmptyStrings: options?.preserveEmptyStrings })
1054}
1055
1056const DOT_ENV_LINE =
1057 /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
1058
1059function parseDotEnvContents(lines: string): Record<string, string> {
1060 const obj: Record<string, string> = Object.create(null)
1061
1062 // Convert line breaks to same format
1063 lines = lines.replace(/\r\n?/gm, "\n")
1064
1065 let match: RegExpExecArray | null
1066 while ((match = DOT_ENV_LINE.exec(lines)) != null) {
1067 const key = match[1]
1068
1069 // Default undefined or null to empty string
1070 let value = match[2] || ""
1071
1072 // Remove whitespace
1073 value = value.trim()
1074
1075 // Check if double quoted
1076 const maybeQuote = value[0]
1077
1078 // Remove surrounding quotes
1079 value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2")
1080
1081 // Expand newlines if double quoted
1082 if (maybeQuote === "\"") {
1083 value = value.replace(/\\n/g, "\n")
1084 value = value.replace(/\\r/g, "\r")
1085 }
1086
1087 // Add to object
1088 obj[key] = value
1089 }
1090
1091 return obj
1092}
1093
1094function dotEnvExpand(parsed: Record<string, string>): Record<string, string> {
1095 const newParsed: Record<string, string> = Object.create(null)
1096
1097 for (const configKey of Object.keys(parsed)) {
1098 // resolve escape sequences
1099 newParsed[configKey] = interpolate(parsed[configKey], parsed).replace(/\\\$/g, "$")
1100 }
1101
1102 return newParsed
1103}
1104
1105function interpolate(envValue: string, parsed: Record<string, string>): string {
1106 // find the last unescaped dollar sign in the
1107 // value so that we can evaluate it
1108 const lastUnescapedDollarSignIndex = searchLast(envValue, /(?!(?<=\\))\$/g)
1109
1110 // If we couldn't match any unescaped dollar sign
1111 // let's return the string as is
1112 if (lastUnescapedDollarSignIndex === -1) return envValue
1113
1114 // This is the right-most group of variables in the string
1115 const rightMostGroup = envValue.slice(lastUnescapedDollarSignIndex)
1116
1117 /**
1118 * This finds the inner most variable/group divided
1119 * by variable name and default value (if present)
1120 * (
1121 * (?!(?<=\\))\$ // only match dollar signs that are not escaped
1122 * {? // optional opening curly brace
1123 * ([\w]+) // match the variable name
1124 * (?::-([^}\\]*))? // match an optional default value
1125 * }? // optional closing curly brace
1126 * )
1127 */
1128 const matchGroup = /((?!(?<=\\))\${?([\w]+)(?::-([^}\\]*))?}?)/
1129 const match = rightMostGroup.match(matchGroup)
1130
1131 if (match !== null) {
1132 const [_, group, variableName, defaultValue] = match
1133 const value = Object.hasOwn(parsed, variableName) && parsed[variableName] !== ""
1134 ? parsed[variableName]
1135 : defaultValue ?? ""
1136
1137 return interpolate(
1138 envValue.replace(group, () => value),
1139 parsed
1140 )
1141 }
1142
1143 return envValue
1144}
1145
1146function searchLast(str: string, rgx: RegExp): number {
1147 const matches = Array.from(str.matchAll(rgx))
1148 return matches.length > 0 ? matches.slice(-1)[0].index : -1
1149}
1150
1151/**
1152 * Creates a `ConfigProvider` by reading and parsing a `.env` file from the
1153 * file system.
1154 *
1155 * **When to use**
1156 *
1157 * Use to load environment config from a `.env` file at application startup.
1158 *
1159 * **Details**
1160 *
1161 * Requires `FileSystem` in the Effect context. Defaults to reading `".env"` in
1162 * the current directory; override with `{ path: "/custom/.env" }`.
1163 * Variable expansion (for example, `${VAR}`) is disabled by default; enable
1164 * with `{ expandVariables: true }`.
1165 *
1166 * Literal empty strings are treated as missing values when loaded as values by
1167 * default. Pass `{ preserveEmptyStrings: true }` to keep empty strings as
1168 * explicit values. Child discovery still reflects the keys present in the
1169 * parsed `.env` source.
1170 *
1171 * Returns an `Effect` that resolves to a `ConfigProvider`. Fails with a
1172 * `PlatformError` if the file cannot be read.
1173 *
1174 * **Example** (Loading a .env file)
1175 *
1176 * ```ts import.meta.vitest
1177 * import { ConfigProvider, Effect, FileSystem } from "effect"
1178 *
1179 * const fileSystem = FileSystem.makeNoop({
1180 * readFileString: () => Effect.succeed("HOST=localhost")
1181 * })
1182 *
1183 * const program = Effect.gen(function*() {
1184 * const provider = yield* ConfigProvider.fromDotEnv()
1185 * return yield* provider.load(["HOST"])
1186 * })
1187 *
1188 * const node = await Effect.runPromise(
1189 * Effect.provideService(program, FileSystem.FileSystem, fileSystem)
1190 * )
1191 * node?.value // => "localhost"
1192 * ```
1193 *
1194 * @see {@link fromDotEnvContents} – parse a `.env` string directly
1195 * @see {@link fromEnv} – read from the runtime environment
1196 *
1197 * @category constructors
1198 * @since 4.0.0
1199 */
1200export const fromDotEnv: (options?: {
1201 readonly path?: string | undefined
1202 readonly expandVariables?: boolean | undefined
1203 readonly preserveEmptyStrings?: boolean | undefined
1204}) => Effect.Effect<ConfigProvider, PlatformError, FileSystem.FileSystem> = Effect.fnUntraced(
1205 function*(options) {
1206 const fs = yield* FileSystem.FileSystem
1207 const content = yield* fs.readFileString(options?.path ?? ".env")
1208 return fromDotEnvContents(content, options)
1209 }
1210)
1211
1212/**
1213 * Creates a `ConfigProvider` that reads configuration from a directory tree
1214 * on disk, where each file is a leaf value and each directory is a container.
1215 *
1216 * **When to use**
1217 *
1218 * Use when you expose each config key as a file under a directory, such as
1219 * Kubernetes ConfigMap or Secret volume mounts.
1220 *
1221 * **Details**
1222 *
1223 * Resolution tries a regular file first and returns a `Value` node for
1224 * non-empty trimmed file contents. If the file read fails, it tries a directory
1225 * and returns a `Record` node with immediate child names as keys. If both fail
1226 * with `NotFound`, it returns `undefined`. Other platform failures return
1227 * `SourceError`.
1228 *
1229 * Requires `Path` and `FileSystem` in the Effect context. Defaults to root
1230 * path `/`; override with `{ rootPath: "/etc/config" }`.
1231 *
1232 * Literal empty strings are treated as missing values by default after file
1233 * contents are trimmed. Pass `{ preserveEmptyStrings: true }` to keep empty
1234 * strings as explicit values. Directory listings still reflect the file names
1235 * present on disk.
1236 *
1237 * **Example** (Reading config from a directory)
1238 *
1239 * ```ts import.meta.vitest
1240 * import { ConfigProvider, Effect, FileSystem, Path } from "effect"
1241 *
1242 * const fileSystem = FileSystem.makeNoop({
1243 * readFileString: (path) =>
1244 * path === "/etc/myapp/host"
1245 * ? Effect.succeed("localhost")
1246 * : Effect.die("unexpected path")
1247 * })
1248 *
1249 * const program = Effect.gen(function*() {
1250 * const provider = yield* ConfigProvider.fromDir({
1251 * rootPath: "/etc/myapp"
1252 * })
1253 * return yield* provider.load(["host"])
1254 * })
1255 *
1256 * const node = await Effect.runPromise(
1257 * program.pipe(
1258 * Effect.provide(Path.layer),
1259 * Effect.provideService(FileSystem.FileSystem, fileSystem)
1260 * )
1261 * )
1262 * node?.value // => "localhost"
1263 * ```
1264 *
1265 * @see {@link fromEnv} – for environment variables
1266 * @see {@link fromDotEnv} – for `.env` files
1267 *
1268 * @category constructors
1269 * @since 4.0.0
1270 */
1271export const fromDir: (options?: {
1272 readonly rootPath?: string | undefined
1273 readonly preserveEmptyStrings?: boolean | undefined
1274}) => Effect.Effect<
1275 ConfigProvider,
1276 never,
1277 Path_.Path | FileSystem.FileSystem
1278> = Effect.fnUntraced(function*(options) {
1279 const platformPath = yield* Path_.Path
1280 const fs = yield* FileSystem.FileSystem
1281 const rootPath = options?.rootPath ?? "/"
1282 const preserveEmptyStrings = options?.preserveEmptyStrings === true
1283
1284 return make((path) => {
1285 const fullPath = platformPath.join(rootPath, ...path.map(String))
1286
1287 // Try reading as a *file*
1288 const asFile = fs.readFileString(fullPath).pipe(
1289 Effect.map((content) => stringNode(content.trim(), preserveEmptyStrings))
1290 )
1291
1292 // If not a file, try reading as a *directory*
1293 const asDirectory = fs.readDirectory(fullPath).pipe(
1294 Effect.map((entries) => makeRecord(new Set(entries.map((entry) => platformPath.basename(entry)))))
1295 )
1296
1297 return asFile.pipe(
1298 Effect.catch((fileCause) =>
1299 asDirectory.pipe(
1300 Effect.catch((dirCause) =>
1301 isNotFound(fileCause) && isNotFound(dirCause)
1302 ? Effect.succeed(undefined)
1303 : Effect.fail(isNotFound(fileCause) ? dirCause : fileCause)
1304 )
1305 )
1306 ),
1307 Effect.mapError((cause: PlatformError) =>
1308 new SourceError({
1309 message: `Failed to read file at ${platformPath.join(rootPath, ...path.map(String))}`,
1310 cause
1311 })
1312 )
1313 )
1314 })
1315})
1316
1317const isNotFound = (cause: PlatformError) => cause.reason._tag === "NotFound"