diff --git a/.changeset/config-provider-option-lookup.md b/.changeset/config-provider-option-lookup.md index dceee916912..a671ebb39fc 100644 --- a/.changeset/config-provider-option-lookup.md +++ b/.changeset/config-provider-option-lookup.md @@ -2,12 +2,12 @@ "effect": patch --- -Refine the `ConfigProvider` interface so lookup absence is explicit and path -transformation is provider behavior. +Refine the `ConfigProvider` interface so lookup absence uses `undefined` and +path transformation is provider behavior. `ConfigProvider.load` and the lookup function accepted by -`ConfigProvider.make` now return `Option`. Use `Option.none()` when a path -does not exist and `Option.some(node)` when it does. +`ConfigProvider.make` now return `Node | undefined`. Use `undefined` when a path +does not exist and return the `Node` directly when it does. `ConfigProvider` now exposes `mapInput` as a capability. The exported `ConfigProvider.mapInput` combinator delegates to it, preserving transformation diff --git a/.changeset/fix-config-or-else-evidence.md b/.changeset/fix-config-or-else-evidence.md new file mode 100644 index 00000000000..ced02896e08 --- /dev/null +++ b/.changeset/fix-config-or-else-evidence.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve provider input evidence when `Config.orElse` recovers a configuration failure. diff --git a/.changeset/refine-config-absence.md b/.changeset/refine-config-absence.md new file mode 100644 index 00000000000..bd2a1c58c78 --- /dev/null +++ b/.changeset/refine-config-absence.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Refine `Config` loading and absence semantics. `Config.schema` now derives a provider loading policy from the encoded `StringTree` schema, materializes mixed-shape union members independently, and leaves separated scalar parsing to `Config.Array` and `Config.Record`. Schemas whose canonical `StringTree` encoding remains opaque, such as `Schema.Any`, `Schema.Unknown`, or `Schema.Json`, are rejected when the config is constructed; use a concrete shape or `Schema.fromJsonString(Schema.Json)` for scalar JSON. Missing or unavailable representations are decoded as `undefined` before `Config.withDefault` and `Config.option` decide absence. Partially supplied `Config.all` groups are rejected, successful values such as `undefined` and explicitly present empty structures are preserved, and the internal path prefix is removed from the public `Config.parse` signature. diff --git a/migration/annotations/effect__Config.yaml b/migration/annotations/effect__Config.yaml index 3c70cb623ba..122eb53712e 100644 --- a/migration/annotations/effect__Config.yaml +++ b/migration/annotations/effect__Config.yaml @@ -1,6 +1,6 @@ "effect/Config#all": replacement: "Config.all" - note: "Unchanged; combine an iterable or record of Config values." + note: "Combine an iterable or record of Config values. A wholly absent product can use Config.withDefault or Config.option, while a partially supplied product fails." "effect/Config#array": replacement: "Config.schema(Config.Array(valueSchema), path)" note: "Array parsing is schema-based in v4; rebuild the element Config as a Schema and pass the optional path to Config.schema." @@ -15,7 +15,7 @@ note: "Collection parsing is schema-based in v4; use Schema.Chunk when a Chunk result is still required." "effect/Config#Config": replacement: "Config.Config" - note: "The model remains and is still a yieldable Effect; it now also exposes parse(provider, pathPrefix?)." + note: "The model remains a yieldable Effect and exposes parse(provider). Compose logical lookup paths with Config.schema(..., path) and Config.nested; parsing no longer accepts a public path prefix." "effect/Config#Config.IsPlainObject": replacement: "none" note: "This private conditional helper is no longer exposed; use Config.Wrap for the public recursive wrapping contract." @@ -72,7 +72,7 @@ note: "Unchanged." "effect/Config#primitive": replacement: "Config.schema(customSchema, path)" - note: "Custom primitive parsing moved to Schema codecs; express decoding and diagnostics in a Schema, then pass it to Config.schema." + note: "Custom primitive parsing moved to Schema codecs; express decoding and diagnostics in a Schema, then pass it to Config.schema. Its canonical StringTree encoding must expose a concrete shape; opaque encodings such as Schema.Any or Schema.Unknown are not supported." "effect/Config#redacted": replacement: "Config.redacted" note: "The string/path overload remains; replace the v3 Config argument overload with Config.map(config, Redacted.make)." diff --git a/migration/annotations/effect__ConfigError.yaml b/migration/annotations/effect__ConfigError.yaml index dca59aaa0c2..51146f3ab98 100644 --- a/migration/annotations/effect__ConfigError.yaml +++ b/migration/annotations/effect__ConfigError.yaml @@ -29,11 +29,11 @@ replacement: "Schema.isSchemaError(error.cause)" note: "Parsing and validation failures are SchemaError causes; inspect the contained SchemaIssue for finer classification." "effect/ConfigError#isMissingData": - replacement: "Schema.isSchemaError(error.cause)" - note: "After narrowing to SchemaError, recursively inspect MissingKey, absent InvalidType/InvalidValue, Pointer, and aggregate issues; there is no public one-step guard." + replacement: "none" + note: "Do not infer semantic absence from a SchemaIssue. Use Config.withDefault or Config.option; they distinguish absent provider input from successful undefined, invalid input, and partial products." "effect/ConfigError#isMissingDataOnly": replacement: "Config.withDefault / Config.option" - note: "The public classifier was removed; these combinators retain the supported missing-only fallback behavior." + note: "The public classifier was removed. These combinators use provider lookup evidence rather than recursively classifying SchemaIssue values." "effect/ConfigError#isOr": replacement: "error.cause.issue._tag === \"AnyOf\"" note: "After narrowing cause with Schema.isSchemaError, inspect the SchemaIssue tag; the old Or node no longer exists." @@ -44,8 +44,8 @@ replacement: "none" note: "The Unsupported variant was removed; report unsupported custom decoding through a SchemaError or source failures through ConfigProvider.SourceError." "effect/ConfigError#MissingData": - replacement: "new Config.ConfigError(new Schema.SchemaError(new SchemaIssue.MissingKey(...)))" - note: "Missing configuration is represented by Schema issues, commonly MissingKey under one or more Pointer nodes." + replacement: "none" + note: "There is no public missing-data error variant. A required absent config ultimately fails with a SchemaError, while Config.withDefault and Config.option handle semantic absence before it enters the public Effect error channel." "effect/ConfigError#Options": replacement: "none" note: "The shared constructor options type was removed; ConfigProvider.SourceError accepts message and optional cause, while Schema issues have issue-specific constructors." diff --git a/migration/annotations/effect__ConfigProvider.yaml b/migration/annotations/effect__ConfigProvider.yaml index 60d71fce73b..7da7c04bba6 100644 --- a/migration/annotations/effect__ConfigProvider.yaml +++ b/migration/annotations/effect__ConfigProvider.yaml @@ -1,6 +1,6 @@ "effect/ConfigProvider#ConfigProvider": replacement: "ConfigProvider.ConfigProvider" - note: "The model remains but now exposes `load(path)`, returning `Effect, SourceError>`, and `mapInput(f)` for provider-owned path transformation. `Option.none()` means the path is missing; `Option.some(node)` means it exists." + note: "The model remains but now exposes `load(path)`, returning `Effect`, and `mapInput(f)` for provider-owned path transformation. `undefined` means the path is missing; a `Node` means it exists." "effect/ConfigProvider#ConfigProvider.Flat": replacement: "ConfigProvider.ConfigProvider" note: "Flat providers were removed; implement the unified path-based provider with ConfigProvider.make." @@ -33,7 +33,7 @@ note: "The constructor remains; pass env and preserveEmptyStrings options. Paths use underscore semantics, while sequence separators belong on Config schemas." "effect/ConfigProvider#fromFlat": replacement: "ConfigProvider.make" - note: "Flat providers were unified with ConfigProvider; implement lookup by returning `Option.some(node)` for a found `Value`, `Record`, or `Array` node, or `Option.none()` when missing." + note: "Flat providers were unified with ConfigProvider; return a `Value`, `Record`, or `Array` node for a found path, or `undefined` when missing." "effect/ConfigProvider#fromJson": replacement: "ConfigProvider.fromUnknown" note: "Renamed to reflect support for any in-memory JavaScript value." @@ -48,10 +48,10 @@ note: "Transform string path segments explicitly with mapInput." "effect/ConfigProvider#make": replacement: "ConfigProvider.make" - note: "The constructor now takes a path lookup returning `Effect, SourceError>`, rather than a full Config loader and flattened provider. Use `Option.none()` for a missing path and `Option.some(node)` for a found node." + note: "The constructor now takes a path lookup returning `Effect`, rather than a full Config loader and flattened provider. Return `undefined` for a missing path and a `Node` for a found path." "effect/ConfigProvider#makeFlat": replacement: "ConfigProvider.make" - note: "The flat-provider constructor was removed; return `Option.some(node)` for a found `Value`, `Record`, or `Array` node, or `Option.none()` when missing." + note: "The flat-provider constructor was removed; return a `Value`, `Record`, or `Array` node for a found path, or `undefined` when missing." "effect/ConfigProvider#mapInputPath": replacement: "ConfigProvider.mapInput" note: "Renamed and generalized: the callback receives and returns the complete Path, including numeric array indexes." diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index 8bf70a031ef..e58f167b984 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -2,7 +2,7 @@ # v3 to v4 Migration Reference -Base: `v3` (`3d390f232bdbc3f0d3d6a2ae3c775084f494b547`) +Base: `3d390f232bdbc3f0d3d6a2ae3c775084f494b547` (`3d390f232bdbc3f0d3d6a2ae3c775084f494b547`) Head: `main` (`24e0e93dc307dc2c2ae86caacb7289e1dab3c103`) @@ -8947,7 +8947,7 @@ Schema.toArbitraryLazy(schema) ### `effect/Config` -- `Config.Config` -> `Config.Config`: The model remains and is still a yieldable Effect; it now also exposes parse(provider, pathPrefix?). +- `Config.Config` -> `Config.Config`: The model remains a yieldable Effect and exposes parse(provider). Compose logical lookup paths with Config.schema(..., path) and Config.nested; parsing no longer accepts a public path prefix. - `Config.Config.IsPlainObject` -> `none`: This private conditional helper is no longer exposed; use Config.Wrap for the public recursive wrapping contract. @@ -8959,7 +8959,7 @@ Schema.toArbitraryLazy(schema) - `Config.LiteralValue` -> `SchemaAST.LiteralValue`: Use the literal value type shared by v4 Schema constructors. -- `Config.all` -> `Config.all`: Unchanged; combine an iterable or record of Config values. +- `Config.all` -> `Config.all`: Combine an iterable or record of Config values. A wholly absent product can use Config.withDefault or Config.option, while a partially supplied product fails. - `Config.array` -> `Config.schema(Config.Array(valueSchema), path)`: Array parsing is schema-based in v4; rebuild the element Config as a Schema and pass the optional path to Config.schema. @@ -8995,7 +8995,7 @@ Schema.toArbitraryLazy(schema) - `Config.port` -> `Config.port`: Unchanged. -- `Config.primitive` -> `Config.schema(customSchema, path)`: Custom primitive parsing moved to Schema codecs; express decoding and diagnostics in a Schema, then pass it to Config.schema. +- `Config.primitive` -> `Config.schema(customSchema, path)`: Custom primitive parsing moved to Schema codecs; express decoding and diagnostics in a Schema, then pass it to Config.schema. Its canonical StringTree encoding must expose a concrete shape; opaque encodings such as Schema.Any or Schema.Unknown are not supported. - `Config.redacted` -> `Config.redacted`: The string/path overload remains; replace the v3 Config argument overload with Config.map(config, Redacted.make). @@ -9037,7 +9037,7 @@ Schema.toArbitraryLazy(schema) - `ConfigError.InvalidData` -> `new Config.ConfigError(new Schema.SchemaError(issue))`: Invalid configuration is now expressed as a SchemaIssue wrapped by SchemaError and Config.ConfigError. -- `ConfigError.MissingData` -> `new Config.ConfigError(new Schema.SchemaError(new SchemaIssue.MissingKey(...)))`: Missing configuration is represented by Schema issues, commonly MissingKey under one or more Pointer nodes. +- `ConfigError.MissingData` -> `none`: There is no public missing-data error variant. A required absent config ultimately fails with a SchemaError, while Config.withDefault and Config.option handle semantic absence before it enters the public Effect error channel. - `ConfigError.Options` -> `none`: The shared constructor options type was removed; ConfigProvider.SourceError accepts message and optional cause, while Schema issues have issue-specific constructors. @@ -9053,9 +9053,9 @@ Schema.toArbitraryLazy(schema) - `ConfigError.isInvalidData` -> `Schema.isSchemaError(error.cause)`: Parsing and validation failures are SchemaError causes; inspect the contained SchemaIssue for finer classification. -- `ConfigError.isMissingData` -> `Schema.isSchemaError(error.cause)`: After narrowing to SchemaError, recursively inspect MissingKey, absent InvalidType/InvalidValue, Pointer, and aggregate issues; there is no public one-step guard. +- `ConfigError.isMissingData` -> `none`: Do not infer semantic absence from a SchemaIssue. Use Config.withDefault or Config.option; they distinguish absent provider input from successful undefined, invalid input, and partial products. -- `ConfigError.isMissingDataOnly` -> `Config.withDefault / Config.option`: The public classifier was removed; these combinators retain the supported missing-only fallback behavior. +- `ConfigError.isMissingDataOnly` -> `Config.withDefault / Config.option`: The public classifier was removed. These combinators use provider lookup evidence rather than recursively classifying SchemaIssue values. - `ConfigError.isOr` -> `error.cause.issue._tag === "AnyOf"`: After narrowing cause with Schema.isSchemaError, inspect the SchemaIssue tag; the old Or node no longer exists. @@ -9069,7 +9069,7 @@ Schema.toArbitraryLazy(schema) ### `effect/ConfigProvider` -- `ConfigProvider.ConfigProvider` -> `ConfigProvider.ConfigProvider`: The model remains but now exposes `load(path)`, returning `Effect, SourceError>`, and `mapInput(f)` for provider-owned path transformation. `Option.none()` means the path is missing; `Option.some(node)` means it exists. +- `ConfigProvider.ConfigProvider` -> `ConfigProvider.ConfigProvider`: The model remains but now exposes `load(path)`, returning `Effect`, and `mapInput(f)` for provider-owned path transformation. `undefined` means the path is missing; a `Node` means it exists. - `ConfigProvider.ConfigProvider.Flat` -> `ConfigProvider.ConfigProvider`: Flat providers were removed; implement the unified path-based provider with ConfigProvider.make. @@ -9091,7 +9091,7 @@ Schema.toArbitraryLazy(schema) - `ConfigProvider.fromEnv` -> `ConfigProvider.fromEnv`: The constructor remains; pass env and preserveEmptyStrings options. Paths use underscore semantics, while sequence separators belong on Config schemas. -- `ConfigProvider.fromFlat` -> `ConfigProvider.make`: Flat providers were unified with ConfigProvider; implement lookup by returning `Option.some(node)` for a found `Value`, `Record`, or `Array` node, or `Option.none()` when missing. +- `ConfigProvider.fromFlat` -> `ConfigProvider.make`: Flat providers were unified with ConfigProvider; return a `Value`, `Record`, or `Array` node for a found path, or `undefined` when missing. - `ConfigProvider.fromJson` -> `ConfigProvider.fromUnknown`: Renamed to reflect support for any in-memory JavaScript value. @@ -9101,9 +9101,9 @@ Schema.toArbitraryLazy(schema) - `ConfigProvider.lowerCase` -> `ConfigProvider.mapInput((path) => path.map((part) => typeof part === "string" ? part.toLowerCase() : part))`: Transform string path segments explicitly with mapInput. -- `ConfigProvider.make` -> `ConfigProvider.make`: The constructor now takes a path lookup returning `Effect, SourceError>`, rather than a full Config loader and flattened provider. Use `Option.none()` for a missing path and `Option.some(node)` for a found node. +- `ConfigProvider.make` -> `ConfigProvider.make`: The constructor now takes a path lookup returning `Effect`, rather than a full Config loader and flattened provider. Return `undefined` for a missing path and a `Node` for a found path. -- `ConfigProvider.makeFlat` -> `ConfigProvider.make`: The flat-provider constructor was removed; return `Option.some(node)` for a found `Value`, `Record`, or `Array` node, or `Option.none()` when missing. +- `ConfigProvider.makeFlat` -> `ConfigProvider.make`: The flat-provider constructor was removed; return a `Value`, `Record`, or `Array` node for a found path, or `undefined` when missing. - `ConfigProvider.mapInputPath` -> `ConfigProvider.mapInput`: Renamed and generalized: the callback receives and returns the complete Path, including numeric array indexes. diff --git a/packages/effect/CONFIG.md b/packages/effect/CONFIG.md index 8f0379c2fce..8b932b79019 100644 --- a/packages/effect/CONFIG.md +++ b/packages/effect/CONFIG.md @@ -97,11 +97,22 @@ Each constructor reads a single value and decodes it into the appropriate type. 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. +### Parsing and Path Ownership + +A `Config` exposes `parse(provider)`; lookup prefixes are not part of this public method. Build paths declaratively with the constructor's `name` / `path` argument and `Config.nested`. + +This keeps the two path responsibilities separate: + +- `Config.schema(..., path)` and `Config.nested(name)` describe the logical path of a setting. +- `ConfigProvider.mapInput`, `ConfigProvider.nested`, and case-conversion combinators map logical paths to a source. + +The same rule applies when a `Config` is yielded as an `Effect`: the config uses the current `ConfigProvider`, while its internally composed logical path stays an implementation detail. + ## Config Combinators -### `Config.withDefault` — Fallback for Missing Keys +### `Config.withDefault` — Fallback for Absent Input -Only triggers when data is missing. Validation errors (wrong type, out of range) still propagate. +Triggers when the config cannot resolve and none of its relevant provider input is present. Validation errors and partially supplied groups still propagate. ```ts import { Config, ConfigProvider, Effect } from "effect" @@ -114,7 +125,7 @@ Effect.runSync(port.parse(provider)) // 3000 ### `Config.option` — Optional Values -Returns `Option.some(value)` on success and `Option.none()` when data is missing. +Returns `Option.some(value)` on success and `Option.none()` when the config is absent. A successful `undefined` value is still a success, so a schema that accepts missing input produces `Option.some(undefined)`, not `Option.none()`. ```ts import { Config, ConfigProvider, Effect } from "effect" @@ -204,7 +215,7 @@ Effect.runSync(config.parse(provider)) // "localhost" ### `Config.all` — Combine Multiple Configs -Accepts a record or a tuple: +Accepts a record or a tuple. A wholly absent group can be handled by `Config.withDefault` or `Config.option`. If any child reads provider input, every other required child must also resolve; partial groups fail instead of silently replacing user input with a whole-group default. ```ts import { Config } from "effect" @@ -220,6 +231,66 @@ const appConfig = Config.all({ const pair = Config.all([Config.string("a"), Config.int("b")]) ``` +For example, providing only `host` is an error here: + +```ts +import { Config } from "effect" + +const database = Config.all({ + host: Config.string("host"), + port: Config.int("port") +}).pipe( + Config.withDefault({ host: "localhost", port: 5432 }) +) +``` + +The default applies when both keys are absent, but not when only one key is present. Defaults on individual children do not count as provider input: + +```ts +const listener = Config.all({ + host: Config.string("host"), + port: Config.int("port").pipe(Config.withDefault(8080)) +}).pipe(Config.option) +``` + +`listener` is `None` when both keys are absent, `Some` when `host` is present, and fails when only `port` is present. + +### How Absence Is Decided + +Configuration evaluation distinguishes three situations before producing the public `Effect`: + +1. **Resolved** — decoding succeeded. The value may legitimately be `undefined`, `{}`, or `[]`. +2. **Absent** — the config could not resolve and no relevant provider representation was found. +3. **Failed** — the provider failed, input was invalid, or a combined config was only partially supplied. + +`Config.withDefault` and `Config.option` handle only the second case. `Config.orElse` handles both absence and failures. + +At the lookup path of a `Config.schema`, an unavailable representation is passed to the schema decoder as `undefined`. This includes a missing node and a present node whose shape cannot represent the schema: for example, an array node cannot represent a struct. Missing properties inside an object remain omitted so the schema's property semantics still apply. The decoder runs before absence is decided. Consequently: + +- `Config.schema(Schema.UndefinedOr(Schema.String), "key")` succeeds with `undefined` when `key` is absent. +- An explicitly present empty object can decode to `{}` when the schema permits it. +- Wrapping either successful result in `Config.option` produces `Some`, because decoding succeeded. +- If the schema rejects `undefined` and no relevant representation was found, `Config.withDefault` uses its fallback and `Config.option` returns `None`. +- Present invalid data and partially supplied `Config.all` groups are failures. +- `SourceError` is always a failure and is never replaced by `withDefault` or `option`. + +`Config.schema(Schema.Struct(...))` and `Config.all(...)` share the same decoder-first rule but describe different lookup models. A struct schema owns one structured input, so an explicitly present empty object is relevant input and its required fields are validated. `Config.all` evaluates independent child configs; an empty parent object does not make the group present when every child is absent. Field optionality in `Config.all` is expressed on each child with `Config.option` or `Config.withDefault`. + +### How Schema Input Is Loaded + +`Config.schema` converts its codec to the canonical `Schema.StringTree` codec and uses the encoded AST to decide which provider representation to load: + +- A scalar schema reads the node's scalar value. A record or array node may have a co-located scalar value in addition to its children. +- A struct loads its declared properties and omits children that the provider does not contain. A record schema also loads advertised keys that match its index signature. +- An array or tuple loads its indexed children. Missing positions are represented as `undefined` so the element schema decides whether they are valid. +- A union whose members require different shapes materializes each member independently. Schema then applies the union's declared order or `oneOf` rule and any checks attached to the original union. + +This keeps the provider responsible only for reporting what exists. Schema remains responsible for deciding whether the loaded representation is valid. + +Plain `Schema.Array` and `Schema.Record` accept structural provider input only. Use `Config.Array` for separated scalar input such as `"a,b,c"`, and `Config.Record` for input such as `"a=1,b=2"`. + +The canonical `StringTree` encoding must expose a concrete scalar, object, array, or union shape. `Config.schema` rejects opaque encodings such as `Schema.Any`, `Schema.Unknown`, `Schema.ObjectKeyword`, `Schema.Json`, and `Schema.MutableJson` synchronously when the config is constructed, including when they are nested in another schema. Suspended recursive schemas and declarations such as `Schema.URL` remain supported when their eventual canonical encoding has a concrete shape. To read arbitrary JSON from one scalar provider value, use `Schema.fromJsonString(Schema.Json)`. + ### 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`. @@ -236,19 +307,20 @@ For reusable codecs you can pass directly to `Config.schema`: | `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.Array(value)` | `Array` | Also parses flat `"v1,v2"` strings | | `Config.Record(key, value)` | `Record` | Also parses flat `"k1=v1,k2=v2"` strings | ## ConfigProvider Sources The concrete built-in source providers `fromEnv`, `fromDotEnvContents`, `fromDotEnv`, `fromUnknown`, and `fromDir` treat literal empty strings as missing values by default when they are loaded as values. Container discovery still reflects the source structure, so a key or file can appear in a `Record` or `Array` node and then load as missing. Pass `{ preserveEmptyStrings: true }` to preserve empty strings as explicit values. -At the raw provider interface, `load(path)` succeeds with an `Option`: -`Option.some(node)` means the path exists, while `Option.none()` means it does -not. A `SourceError` represents a failure to read the source and remains in the -Effect error channel. +At the raw provider interface, `load(path)` succeeds with `Node | undefined`: a +`Node` means the path exists, while `undefined` means it does not. A +`SourceError` represents a failure to read the source and remains in the Effect +error channel. -This lookup-level `Option` is distinct from the `value` field of a found -`Record` or `Array` node. Such a container can exist while +Lookup-level `undefined` is distinct from the `value` field of a found `Record` +or `Array` node. Such a container can exist while `node.value === undefined`, which means that it has children but no co-located scalar value. @@ -370,14 +442,14 @@ const program = Effect.gen(function*() { Requires `Path` and `FileSystem` in the Effect context. -Missing files and directories return `Option.none()`, so fallback providers can handle the path. Empty files also return `Option.none()` by default after trimming their contents, while directory listings still report the file names present on disk; pass `{ preserveEmptyStrings: true }` to preserve them as `Value("")`. Other file-system failures are reported as `SourceError`. +Missing files and directories return `undefined`, so fallback providers can handle the path. Empty files also return `undefined` by default after trimming their contents, while directory listings still report the file names present on disk; pass `{ preserveEmptyStrings: true }` to preserve them as `Value("")`. Other file-system failures are reported as `SourceError`. ### `ConfigProvider.make` — Custom Sources Build a provider from any backing store: ```ts -import { ConfigProvider, Effect, Option } from "effect" +import { ConfigProvider, Effect } from "effect" const data: Record = { host: "localhost", @@ -388,23 +460,21 @@ const provider = ConfigProvider.make((path) => { const key = path.join(".") const value = data[key] return Effect.succeed( - value !== undefined - ? Option.some(ConfigProvider.makeValue(value)) - : Option.none() + value !== undefined ? ConfigProvider.makeValue(value) : undefined ) }) ``` -Return `Option.none()` for "not found" and `Option.some(node)` for a node that -exists. Only fail with `SourceError` when the source itself cannot be read. -Providers created with `make` automatically support the path-transformation -behavior used by `mapInput`, `constantCase`, and `nested`. +Return `undefined` for "not found" and a `Node` for a path that exists. Only +fail with `SourceError` when the source itself cannot be read. Providers created +with `make` automatically support the path-transformation behavior used by +`mapInput`, `constantCase`, and `nested`. ## ConfigProvider Combinators ### `ConfigProvider.orElse` — Fallback Sources -Falls back to a second provider when the first returns `Option.none()` (path not found). Does **not** catch `SourceError`. +Falls back to a second provider when the first returns `undefined` (path not found). Does **not** catch `SourceError`. ```ts import { ConfigProvider } from "effect" @@ -627,6 +697,8 @@ const program = Effect.gen(function*() { const result = Effect.runSync(host.parse(provider)) ``` + The method accepts only the provider. Use `Config.nested` or the path argument of `Config.schema` to scope lookups. + ## Error Handling Config operations fail with `ConfigError`, which wraps either: @@ -654,7 +726,7 @@ const program = Config.int("PORT").parse( ) ``` -**Important**: `Config.withDefault` and `Config.option` only recover from missing-data errors. Validation errors still propagate. +**Important**: `Config.withDefault` and `Config.option` recover only from semantic absence. They do not classify `SchemaIssue` values as “missing.” Validation errors, source failures, and partially supplied groups still propagate. ## Practical Example: Web Server Config diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index f46b46050b8..c3db7934d05 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -63,7 +63,7 @@ export const isConfig = (u: unknown): u is Config => Predicate.hasPrope * (wrong type, out of range, missing key, etc.). * * @see {@link orElse} – recover from a ConfigError - * @see {@link withDefault} – provide a fallback for missing-data errors + * @see {@link withDefault} – provide a fallback when relevant input is absent * * @category errors * @since 4.0.0 @@ -94,9 +94,7 @@ export class ConfigError { * **Details** * * Key members: - * - `parse(provider, pathPrefix?)` – runs the config against a specific provider. - * The optional path prefix is the logical scope accumulated from outer - * `Config.nested` calls. + * - `parse(provider)` – runs the config against a specific provider. * - Yieldable – can be yielded inside `Effect.gen`, which automatically * resolves the current `ConfigProvider` from the context. * - Pipeable – supports `.pipe(Config.map(...))` etc. @@ -108,10 +106,39 @@ export class ConfigError { */ export interface Config extends Effect.Effect { readonly [TypeId]: typeof TypeId - readonly parse: ( - provider: ConfigProvider.ConfigProvider, - pathPrefix?: Path - ) => Effect.Effect + readonly parse: (provider: ConfigProvider.ConfigProvider) => Effect.Effect +} + +// Config composition needs to distinguish an absent recipe from a hard failure +// before the public Effect error channel is finalized. `hasInput` records +// provider evidence separately from the value, because successful values such +// as `undefined` and values supplied by defaults are not evidence of input. +// Hard failures carry the same evidence so recovery cannot erase it. +interface Resolved { + readonly _tag: "Resolved" + readonly value: T + readonly hasInput: boolean +} + +interface Absent { + readonly _tag: "Absent" + readonly error: ConfigError +} + +type Resolution = Resolved | Absent + +interface EvaluationFailure { + readonly error: ConfigError + readonly hasInput: boolean +} + +type Evaluator = ( + provider: ConfigProvider.ConfigProvider, + pathPrefix: Path +) => Effect.Effect, EvaluationFailure> + +interface ConfigImpl extends Config { + readonly evaluator: Evaluator } const Proto = { @@ -130,13 +157,69 @@ const Proto = { } function make( - parse: (provider: ConfigProvider.ConfigProvider, pathPrefix: Path) => Effect.Effect + evaluator: Evaluator ): Config { const self = Object.create(Proto) - self.parse = (provider: ConfigProvider.ConfigProvider, pathPrefix: Path = []) => parse(provider, pathPrefix) + self.evaluator = evaluator + self.parse = (provider: ConfigProvider.ConfigProvider) => + evaluator(provider, []).pipe( + Effect.mapErrorEager((failure) => failure.error), + Effect.flatMapEager((resolution) => + resolution._tag === "Resolved" ? Effect.succeed(resolution.value) : Effect.fail(resolution.error) + ) + ) return self } +const evaluateAt = ( + self: Config, + provider: ConfigProvider.ConfigProvider, + pathPrefix: Path +): Effect.Effect, EvaluationFailure> => (self as ConfigImpl).evaluator(provider, pathPrefix) + +const resolved = (value: T, hasInput: boolean): Resolution => ({ + _tag: "Resolved", + value, + hasInput +}) + +const absent = (error: ConfigError): Absent => ({ + _tag: "Absent", + error +}) + +const evaluationFailure = (error: ConfigError, hasInput: boolean): EvaluationFailure => ({ + error, + hasInput +}) + +const catchSourceError = ( + self: Effect.Effect, + hasInput: boolean +): Effect.Effect => + self.pipe( + Effect.catchDefect((defect) => + defect instanceof ConfigProvider.SourceError + ? Effect.fail(evaluationFailure(new ConfigError(defect), hasInput)) + : Effect.die(defect) + ) + ) + +const preserveInputEvidence = ( + self: Effect.Effect, EvaluationFailure>, + hasInput: boolean +): Effect.Effect, EvaluationFailure> => { + if (!hasInput) return self + return self.pipe( + Effect.mapErrorEager((failure) => evaluationFailure(failure.error, true)), + Effect.flatMapEager((resolution) => + resolution._tag === "Resolved" + ? Effect.succeed(resolved(resolution.value, true)) + : Effect.fail(evaluationFailure(resolution.error, true)) + ) + ) +} + /** * Transforms the parsed value of a config with a pure function. * @@ -167,7 +250,12 @@ export const map: { (f: (a: A) => B): (self: Config) => Config (self: Config, f: (a: A) => B): Config } = dual(2, (self: Config, f: (a: A) => B): Config => { - return make((provider, pathPrefix) => Effect.map(self.parse(provider, pathPrefix), f)) + return make((provider, pathPrefix) => + Effect.map(evaluateAt(self, provider, pathPrefix), (resolution) => + resolution._tag === "Resolved" + ? resolved(f(resolution.value), resolution.hasInput) + : resolution) + ) }) /** @@ -199,7 +287,15 @@ export const mapOrFail: { (f: (a: A) => Effect.Effect): (self: Config) => Config (self: Config, f: (a: A) => Effect.Effect): Config } = dual(2, (self: Config, f: (a: A) => Effect.Effect): Config => { - return make((provider, pathPrefix) => Effect.flatMap(self.parse(provider, pathPrefix), f)) + return make((provider, pathPrefix) => + Effect.flatMap(evaluateAt(self, provider, pathPrefix), (resolution) => + resolution._tag === "Resolved" + ? f(resolution.value).pipe( + Effect.mapEager((value) => resolved(value, resolution.hasInput)), + Effect.mapErrorEager((error) => evaluationFailure(error, resolution.hasInput)) + ) + : Effect.succeed(resolution)) + ) }) /** @@ -212,10 +308,17 @@ export const mapOrFail: { * * **Details** * - * Unlike {@link withDefault}, this catches **all** `ConfigError`s (not just - * missing data). The fallback function receives the error and returns a new + * Unlike {@link withDefault}, this handles both semantic absence and **all** + * `ConfigError`s. The fallback function receives the error and returns a new * `Config`. * + * **Gotchas** + * + * Recovery preserves whether the primary config read provider input. When the + * recovered config is composed with {@link all}, invalid input in the primary + * branch still makes the enclosing group partially supplied, so an outer + * {@link withDefault} or {@link option} does not replace the whole group. + * * **Example** (Falling back to a literal) * * ```ts import.meta.vitest @@ -228,7 +331,7 @@ export const mapOrFail: { * Effect.runSync(hostConfig.parse(provider)) // => "localhost" * ``` * - * @see {@link withDefault} – fallback only on missing data + * @see {@link withDefault} – fallback only on semantic absence * * @category combinators * @since 2.0.0 @@ -237,8 +340,18 @@ export const orElse: { (that: (error: ConfigError) => Config): (self: Config) => Config (self: Config, that: (error: ConfigError) => Config): Config } = dual(2, (self: Config, that: (error: ConfigError) => Config): Config => { - return make((provider, pathPrefix) => - Effect.catch(self.parse(provider, pathPrefix), (error) => that(error).parse(provider, pathPrefix)) + return make((provider, pathPrefix) => + Effect.matchEffect(evaluateAt(self, provider, pathPrefix), { + onFailure: (failure) => + preserveInputEvidence( + evaluateAt(that(failure.error), provider, pathPrefix), + failure.hasInput + ), + onSuccess: (resolution): Effect.Effect, EvaluationFailure> => + resolution._tag === "Absent" + ? evaluateAt(that(resolution.error), provider, pathPrefix) + : Effect.succeed(resolution) + }) ) }) @@ -254,6 +367,16 @@ export const orElse: { * Accepts a tuple (preserves positions), an iterable, or a record of configs. * Returns a config whose parsed value mirrors the input shape. * + * A combined config is absent when at least one child cannot resolve and none + * of the other children read provider input. This lets {@link withDefault} and + * {@link option} handle a wholly absent group. Once any child reads input, a + * missing sibling makes the group incomplete and parsing fails. Values supplied + * by child defaults do not count as provider input. + * + * Unlike a `Schema.Struct` passed to {@link schema}, `all` only considers input + * read by its children. An explicitly present but empty parent container does + * not by itself make the group present. + * * **Example** (Combining configs as a struct) * * ```ts import.meta.vitest @@ -290,46 +413,65 @@ export function all> | Record - Effect.all(configs.map((config) => config.parse(provider, pathPrefix))) + Effect.flatMapEager( + Effect.all(configs.map((config) => evaluateAt(config, provider, pathPrefix))), + resolveArray + ) ) as any } else { return make((provider, pathPrefix) => - Effect.all(Rec.map(configs, (config) => config.parse(provider, pathPrefix))) + Effect.flatMapEager( + Effect.all(Rec.map(configs, (config) => evaluateAt(config, provider, pathPrefix))), + resolveRecord + ) ) as any } } -function isMissingDataOnly(issue: SchemaIssue.Issue): boolean { - switch (issue._tag) { - case "MissingKey": - return true - case "InvalidType": - case "InvalidValue": - return Option.isNone(issue.actual) || (Option.isSome(issue.actual) && issue.actual.value === undefined) - case "OneOf": - return issue.actual === undefined - case "Encoding": - return Option.isNone(issue.actual) || (Option.isSome(issue.actual) && issue.actual.value === undefined) - ? true - : isMissingDataOnly(issue.issue) - case "Pointer": - return isMissingDataOnly(issue.issue) - case "Filter": - case "UnexpectedKey": - case "Forbidden": - return false - case "Composite": - return issue.issues.every(isMissingDataOnly) - case "AnyOf": - if (issue.issues.length === 0) { - return issue.actual === undefined - } - return issue.issues.every(isMissingDataOnly) +const resolveArray = ( + resolutions: ReadonlyArray> +): Effect.Effect>, EvaluationFailure> => { + const values: Array = [] + let firstAbsent: Absent | undefined + let hasInput = false + for (const resolution of resolutions) { + if (resolution._tag === "Absent") { + firstAbsent ??= resolution + } else { + values.push(resolution.value) + hasInput = hasInput || resolution.hasInput + } + } + if (firstAbsent !== undefined) { + return hasInput ? Effect.fail(evaluationFailure(firstAbsent.error, true)) : Effect.succeed(firstAbsent) } + return Effect.succeed(resolved(values, hasInput)) +} + +const resolveRecord = ( + resolutions: Record> +): Effect.Effect>, EvaluationFailure> => { + const values: Record = {} + let firstAbsent: Absent | undefined + let hasInput = false + for (const key in resolutions) { + const resolution = resolutions[key] + if (resolution._tag === "Absent") { + firstAbsent ??= resolution + } else { + InternalRecord.assignProperty(values, key, resolution.value) + hasInput = hasInput || resolution.hasInput + } + } + if (firstAbsent !== undefined) { + return hasInput ? Effect.fail(evaluationFailure(firstAbsent.error, true)) : Effect.succeed(firstAbsent) + } + return Effect.succeed(resolved(values, hasInput)) } /** - * Provides a fallback value when the config fails due to missing data. + * Provides a fallback value when the config cannot resolve because none of its + * relevant input is present. * * **When to use** * @@ -337,9 +479,11 @@ function isMissingDataOnly(issue: SchemaIssue.Issue): boolean { * * **Gotchas** * - * Only applies when the error is a `SchemaError` caused exclusively by - * missing data (missing keys, undefined values). Validation errors (wrong - * type, out of range) still propagate. + * Validation errors and partially supplied groups still propagate. A schema + * that successfully decodes absent input also keeps its decoded value instead + * of using the default. Schema configs first represent a missing or + * incompatible provider shape as `undefined`; the default is used only when + * the schema rejects that value and no relevant input was found. * * **Example** (Defaulting a missing port) * @@ -353,7 +497,7 @@ function isMissingDataOnly(issue: SchemaIssue.Issue): boolean { * ``` * * @see {@link option} – returns `Option` instead of a default value - * @see {@link orElse} – catches all errors, not just missing data + * @see {@link orElse} – catches all errors, not just absent input * * @category combinators * @since 2.0.0 @@ -362,20 +506,17 @@ export const withDefault: { (defaultValue: A2): (self: Config) => Config (self: Config, defaultValue: A2): Config } = dual(2, (self: Config, defaultValue: A2): Config => { - return orElse(self, (err) => { - if (Schema.isSchemaError(err.cause)) { - const issue = err.cause.issue - if (isMissingDataOnly(issue)) { - return succeed(defaultValue) - } - } - return fail(err.cause) - }) + return make((provider, pathPrefix) => + Effect.mapEager( + evaluateAt(self, provider, pathPrefix), + (resolution) => resolution._tag === "Absent" ? resolved(defaultValue, false) : resolution + ) + ) }) /** - * Makes a config optional: returns `Some(value)` on success and `None` when - * data is missing. + * Makes a config optional: returns `Some(value)` on success and `None` when the + * config cannot resolve because none of its relevant input is present. * * **When to use** * @@ -383,8 +524,11 @@ export const withDefault: { * * **Gotchas** * - * Like {@link withDefault}, only missing-data errors produce `None`. - * Validation errors still propagate. + * Validation errors and partially supplied groups still propagate. Successful + * values are always wrapped in `Some`, including `undefined` when the schema + * explicitly accepts it. Schema configs first represent a missing or + * incompatible provider shape as `undefined`; `None` is returned only when the + * schema rejects that value and no relevant input was found. * * **Example** (Reading optional config) * @@ -486,119 +630,145 @@ type IsPlainObject = [A] extends [Record] */ export const unwrap = (wrapped: Wrap): Config => { if (isConfig(wrapped)) return wrapped - return make((provider, pathPrefix) => { - const entries = Object.entries(wrapped) - const configs = entries.map(([key, config]) => - unwrap(config as any).parse(provider, pathPrefix).pipe(Effect.map((value) => [key, value] as const)) - ) - return Effect.all(configs).pipe(Effect.map(Object.fromEntries)) - }) + return all(Rec.map(wrapped as Record>, (config) => unwrap(config))) as Config } // ----------------------------------------------------------------------------- // schema // ----------------------------------------------------------------------------- -const dump: ( +interface ConfigCursor { + readonly provider: ConfigProvider.ConfigProvider + readonly path: Path + readonly node: ConfigProvider.Node | undefined + readonly toString: () => string +} + +const cursorToString = (): string => "" + +const loadCursor: ( provider: ConfigProvider.ConfigProvider, path: Path -) => Effect.Effect = Effect.fnUntraced(function*( - provider, - path -) { - const stat = Option.getOrUndefined(yield* provider.load(path)) - if (stat === undefined) return undefined - switch (stat._tag) { - case "Value": - return stat.value - case "Record": { - if (stat.value !== undefined) return stat.value - const out: Record = {} - for (const key of stat.keys) { - const child = yield* dump(provider, [...path, key]) - if (child !== undefined) InternalRecord.assignProperty(out, key, child) - } - return out - } - case "Array": { - if (stat.value !== undefined) return stat.value - const out: Array = [] - for (let i = 0; i < stat.length; i++) { - out.push(yield* dump(provider, [...path, i])) - } - return out - } +) => Effect.Effect = (provider, path) => + provider.load(path).pipe( + Effect.orDie, + Effect.mapEager((node) => ({ provider, path, node, toString: cursorToString })) + ) + +const loadChildCursor = (cursor: ConfigCursor, segment: string | number): Effect.Effect => + loadCursor(cursor.provider, [...cursor.path, segment]) + +const getScalar = (node: ConfigProvider.Node | undefined): string | undefined => node?.value + +const decodeFromCursor = ( + ast: SchemaAST.AST, + decode: (cursor: ConfigCursor) => Effect.Effect +): SchemaAST.AST => + SchemaAST.decodeTo( + SchemaAST.unknown, + ast, + new SchemaTransformation.Transformation( + SchemaGetter.transformOrFail((input: unknown) => decode(input as ConfigCursor)), + SchemaGetter.passthrough() + ) + ) + +const isScalarInput = (ast: SchemaAST.AST): boolean => { + switch (ast._tag) { + case "Union": + return ast.types.every(isScalarInput) + case "Objects": + case "Arrays": + case "Suspend": + return false + default: + return true } -}) +} -const recur: ( +const hasProviderInput = ( ast: SchemaAST.AST, - provider: ConfigProvider.ConfigProvider, - path: Path -) => Effect.Effect = Effect.fnUntraced( - function*(ast, provider, path) { + node: ConfigProvider.Node | undefined +): boolean => { + switch (ast._tag) { + case "Objects": + return node?._tag === "Record" + case "Arrays": + return node?._tag === "Array" + case "Union": + return ast.types.some((ast) => hasProviderInput(ast, node)) + case "Suspend": + return hasProviderInput(ast.thunk(), node) + default: + return getScalar(node) !== undefined + } +} + +const toConfigCursorAST = (root: SchemaAST.AST): SchemaAST.AST => { + const seen = new WeakSet() + const recur = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => { + seen.add(ast) switch (ast._tag) { case "Objects": { - const stat = Option.getOrUndefined(yield* provider.load(path)) - if (stat === undefined && path.length > 0) return undefined - const out: Record = {} - for (const ps of ast.propertySignatures) { - const name = ps.name - if (typeof name === "string") { - const value = yield* recur(ps.type, provider, [...path, name]) - if (value !== undefined) InternalRecord.assignProperty(out, name, value) + const matchesIndex = ast.indexSignatures.map((is) => SchemaParser._is(is.parameter)) + const materialize = Effect.fnUntraced(function*(cursor: ConfigCursor) { + if (cursor.node?._tag !== "Record") { + return undefined } - } - if (ast.indexSignatures.length > 0) { - if (stat && stat._tag === "Record") { - for (const is of ast.indexSignatures) { - const matches = SchemaParser._is(is.parameter) - for (const key of stat.keys) { - if (!Object.hasOwn(out, key) && matches(key)) { - const value = yield* recur(is.type, provider, [...path, key]) - if (value !== undefined) InternalRecord.assignProperty(out, key, value) - } - } + const node = cursor.node + const keys = new Set() + for (const property of ast.propertySignatures) { + if (typeof property.name === "string") keys.add(property.name) + } + if (matchesIndex.length > 0) { + for (const key of node.keys) { + if (matchesIndex.some((matches) => matches(key))) keys.add(key) } } - } - return out + const out: Record = {} + for (const key of keys) { + const child = yield* loadChildCursor(cursor, key) + if (child.node !== undefined) InternalRecord.assignProperty(out, key, child) + } + return out + }) + return decodeFromCursor(ast.recur(recur, (ast) => ast), materialize) } case "Arrays": { - const stat = Option.getOrUndefined(yield* provider.load(path)) - if (stat === undefined) return undefined - if (stat && stat._tag === "Value") return stat.value === "" ? [] : stat.value.split(",") - if (stat && stat._tag === "Array" && stat.value !== undefined) { - return stat.value === "" ? [] : stat.value.split(",") - } - const out: Array = [] - const length = stat && stat._tag === "Array" ? stat.length : ast.elements.length - for (let i = 0; i < length; i++) { - const element = ast.elements[i] ?? ast.rest[0] - if (element !== undefined) { - out.push(yield* recur(element, provider, [...path, i])) + const materialize = Effect.fnUntraced(function*(cursor: ConfigCursor) { + if (cursor.node?._tag !== "Array") { + return undefined } - } - return out + const out: Array = [] + for (let i = 0; i < cursor.node.length; i++) { + out.push(yield* loadChildCursor(cursor, i)) + } + return out + }) + return decodeFromCursor(ast.recur(recur), materialize) } case "Union": - // Let downstream decoding decide; dump can return a string, object, or array. - return yield* dump(provider, path) - case "Suspend": - return yield* recur(ast.thunk(), provider, path) - default: { - // Base primitives / string-like encoded nodes. - const stat = Option.getOrUndefined(yield* provider.load(path)) - if (stat === undefined) return undefined - if (stat._tag === "Value") return stat.value - if (stat._tag === "Record" && stat.value !== undefined) return stat.value - if (stat._tag === "Array" && stat.value !== undefined) return stat.value - // Container without a co-located value cannot satisfy a scalar request. - return undefined + for (const member of ast.types) { + recur(member) + } + return isScalarInput(ast) + ? decodeFromCursor(ast, (cursor) => Effect.succeed(getScalar(cursor.node))) + : ast.recur(recur) + case "Suspend": { + const target = ast.thunk() + // Force new branches so opaque encodings fail when the Config is constructed. + if (!seen.has(target)) recur(target) + return ast.recur(recur) } + case "Declaration": + case "Any": + throw new globalThis.Error("Config.schema does not support opaque StringTree encodings", { cause: ast }) + default: + return decodeFromCursor(ast, (cursor) => Effect.succeed(getScalar(cursor.node))) } - } -) + }) + return recur(root) +} /** * Creates a `Config` from a `Schema.Codec`. @@ -617,8 +787,43 @@ const recur: ( * Convenience constructors such as `string`, `number`, and `boolean` delegate * to this API. * - * The codec is used to decode the raw `StringTree` produced by the provider - * into `T`. Schema validation errors are wrapped in `ConfigError`. + * The codec is converted to its canonical `StringTree` form. Its encoded shape + * determines how provider data is loaded: scalar schemas read a co-located + * scalar value, object schemas read declared properties and matching record + * keys, and array schemas read indexed children. A mixed-shape union loads each + * member according to that member's shape before applying the union's mode and + * checks. + * + * At the config's lookup path, a missing node or a node that cannot provide the + * representation required by the schema is decoded as `undefined`. Missing + * object properties remain omitted so the schema's property semantics still + * apply. Decoding success always wins, even when no provider input was found. + * For example, + * `Schema.UndefinedOr(Schema.String)` decodes to `undefined` and is not replaced + * by {@link withDefault}. If decoding fails and no relevant representation was + * found, the config is absent. Invalid data in a relevant representation is a + * validation failure. Provider `SourceError`s are always failures. + * + * **Gotchas** + * + * Plain `Schema.Array` and `Schema.Record` schemas use structural provider + * input. Use {@link Array} or {@link Record} when a flat separated string must + * also be accepted. + * + * `Schema.Struct` and {@link all} describe different lookup models. An + * explicitly present empty object is relevant input for a struct and required + * fields are validated. The same empty parent container does not make an + * `all` group present when all of its child configs are absent. + * + * The canonical `StringTree` encoding must expose a concrete scalar, object, + * array, or union shape. Opaque encodings such as `Schema.Any`, + * `Schema.Unknown`, `Schema.ObjectKeyword`, `Schema.Json`, and + * `Schema.MutableJson` are rejected synchronously when this config is + * constructed, including when they are nested in another schema. Suspended + * recursive schemas remain supported when their eventual shape is concrete. + * Declarations such as `Schema.URL` also remain supported when their canonical + * encoding has a concrete shape. To read arbitrary JSON from one scalar value, + * use `Schema.fromJsonString(Schema.Json)`. * * **Example** (Reading a structured config) * @@ -648,20 +853,31 @@ const recur: ( */ export function schema(codec: Schema.ConstraintCodec, path?: string | ConfigProvider.Path): Config { const codecStringTree = Schema.toCodecStringTree(codec) - const decodeUnknownEffect = SchemaParser.decodeUnknownEffect(codecStringTree) - const codecStringTreeEncoded = SchemaAST.toEncoded(codecStringTree.ast) + const encodedAst = SchemaAST.toEncoded(codecStringTree.ast) + const decodeCursor = SchemaParser.decodeUnknownEffect( + Schema.make>(toConfigCursorAST(codecStringTree.ast)) + ) const localPath = typeof path === "string" ? [path] : path ?? [] return make((provider, pathPrefix) => { const fullPath = [...pathPrefix, ...localPath] - return recur(codecStringTreeEncoded, provider, fullPath).pipe( - Effect.flatMapEager((tree) => - decodeUnknownEffect(tree).pipe( - Effect.mapErrorEager((issue) => - new Schema.SchemaError(fullPath.length > 0 ? new SchemaIssue.Pointer(fullPath, issue) : issue) - ) + return catchSourceError(loadCursor(provider, fullPath), false).pipe( + Effect.flatMapEager((cursor) => { + const hasInput = hasProviderInput(encodedAst, cursor.node) + return catchSourceError( + decodeCursor(cursor).pipe( + Effect.mapEager((value) => resolved(value, hasInput)), + Effect.catchEager((issue) => { + const error = new ConfigError( + new Schema.SchemaError(fullPath.length > 0 ? new SchemaIssue.Pointer(fullPath, issue) : issue) + ) + return hasInput + ? Effect.fail(evaluationFailure(error, true)) + : Effect.succeed(absent(error)) + }) + ), + hasInput ) - ), - Effect.mapErrorEager((cause) => new ConfigError(cause)) + }) ) }) } @@ -771,6 +987,8 @@ export const LogLevel = Schema.Literals(LogLevel_.values) * result["custom.attribute"] // => "value" * ``` * + * @see {@link Array} for separated or structural array input + * * @category schemas * @since 4.0.0 */ @@ -779,35 +997,31 @@ export const Record = readonly keyValueSeparator?: string | undefined }) => { const record = Schema.Record(key, value) + const split = SchemaTransformation.splitKeyValue(options) const recordString = Schema.String.pipe( - Schema.decodeTo( - Schema.Record(Schema.String, Schema.String), - SchemaTransformation.splitKeyValue(options) - ), - Schema.decodeTo(record) + Schema.decodeTo(Schema.toCodecStringTree(record), { + decode: split.decode, + encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + split.encode + ) + }) ) return Schema.Union([record, recordString]) } -/** - * @category schemas - * @since 4.0.0 - */ const ArrayConfig = (value: V, options?: { readonly separator?: string | undefined }) => { const array = Schema.Array(value) const separator = options?.separator ?? "," const arrayString = Schema.String.pipe( - Schema.decodeTo( - Schema.Array(Schema.String), - { - decode: SchemaGetter.split(options), - encode: SchemaGetter.transform((input: ReadonlyArray) => input.join(separator)) - } - ), - Schema.decodeTo(array) + Schema.decodeTo(Schema.toCodecStringTree(array), { + decode: SchemaGetter.split(options), + encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + SchemaGetter.transform((input) => input.join(separator)) + ) + }) ) return Schema.Union([arrayString, array]) @@ -827,6 +1041,8 @@ export { * Accepts either a JSON-like array from the provider or a flat string like * `"a,b,c"`. The `separator` defaults to `","` and can be customized. * + * @see {@link Record} for separated or structural record input + * * @category schemas * @since 4.0.0 */ @@ -849,7 +1065,7 @@ export { * @since 2.0.0 */ export function fail(err: SourceError | Schema.SchemaError) { - return make(() => Effect.fail(new ConfigError(err))) + return make(() => Effect.fail(evaluationFailure(new ConfigError(err), false))) } /** @@ -877,7 +1093,7 @@ export function fail(err: SourceError | Schema.SchemaError) { * @since 2.0.0 */ export function succeed(value: T) { - return make(() => Effect.succeed(value)) + return make(() => Effect.succeed(resolved(value, false))) } /** @@ -1392,5 +1608,5 @@ export const nested: { } = dual( 2, (self: Config, name: string): Config => - make((provider, pathPrefix) => self.parse(provider, [...pathPrefix, name])) + make((provider, pathPrefix) => evaluateAt(self, provider, [...pathPrefix, name])) ) diff --git a/packages/effect/src/ConfigProvider.ts b/packages/effect/src/ConfigProvider.ts index ab1a83769fd..34421845713 100644 --- a/packages/effect/src/ConfigProvider.ts +++ b/packages/effect/src/ConfigProvider.ts @@ -17,7 +17,6 @@ import { format } from "./Formatter.ts" import { dual, flow } from "./Function.ts" import { PipeInspectableProto } from "./internal/core.ts" import * as Layer from "./Layer.ts" -import * as Option from "./Option.ts" import * as Path_ from "./Path.ts" import type { Pipeable } from "./Pipeable.ts" import type { PlatformError } from "./PlatformError.ts" @@ -42,8 +41,8 @@ import * as Str from "./String.ts" * `value`. `Array` is an indexed container with a known `length` and may also * carry an optional co-located `value`. * - * Provider lookups use `Option` because a missing node is an outcome of - * the lookup. Within a node that was found, `value: undefined` has a narrower + * Provider lookups return `undefined` when no node exists at the requested + * path. Within a node that was found, `value: undefined` has a narrower * structural meaning: the container exists but has no co-located scalar value. * * @see {@link makeValue} – construct a `Value` node @@ -183,7 +182,7 @@ export function makeArray(length: number, value?: string): Node { * **Gotchas** * * Do not use `SourceError` for "key not found". That case is represented by - * returning `Option.none()` from `load`. + * returning `undefined` from `load`. * * **Example** (Failing with a SourceError) * @@ -246,8 +245,8 @@ export type Path = ReadonlyArray * * `load(path)` is the semantic lookup operation used by the `Config` module. * It applies provider transformations and composition before consulting the - * underlying source. `Option.none()` means "not found", `Option.some(node)` - * means the path exists, and `SourceError` means the source itself failed. + * underlying source. `undefined` means "not found", a `Node` means the path + * exists, and `SourceError` means the source itself failed. * * `mapInput(f)` is the provider's path-transformation capability. Keeping this * capability on the provider allows source and composite providers to preserve @@ -269,9 +268,8 @@ export type Path = ReadonlyArray */ export interface ConfigProvider extends Pipeable { /** - * Returns `Option.some(node)` when `path` exists or `Option.none()` when it - * does not. Fails with `SourceError` when the underlying source cannot be - * read. + * Returns a `Node` when `path` exists or `undefined` when it does not. Fails + * with `SourceError` when the underlying source cannot be read. * * **When to use** * @@ -280,12 +278,12 @@ export interface ConfigProvider extends Pipeable { * * **Details** * - * Lookup absence uses `Option` because it controls provider composition, - * such as whether {@link orElse} consults its fallback. An optional `value` - * inside a found `Record` or `Array` node remains `undefined` because it - * describes the shape of that node rather than the outcome of the lookup. + * Lookup absence controls provider composition, such as whether + * {@link orElse} consults its fallback. An optional `value` inside a found + * `Record` or `Array` node remains `undefined` because it describes the shape + * of that node rather than the outcome of the lookup. */ - readonly load: (path: Path) => Effect.Effect, SourceError> + readonly load: (path: Path) => Effect.Effect /** * Returns a provider that applies `f` to lookup paths after any existing path @@ -357,7 +355,7 @@ const Proto = { const identityPath = (path: Path): Path => path function makeProvider( - load: (path: Path) => Effect.Effect, SourceError>, + load: (path: Path) => Effect.Effect, mapInput: (f: (path: Path) => Path) => ConfigProvider ): ConfigProvider { const self = Object.create(Proto) @@ -367,7 +365,7 @@ function makeProvider( } function makeSource( - get: (path: Path) => Effect.Effect, SourceError>, + get: (path: Path) => Effect.Effect, transform: (path: Path) => Path ): ConfigProvider { return makeProvider( @@ -381,7 +379,7 @@ function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvid (path) => Effect.flatMap( first.load(path), - (node) => Option.isSome(node) ? Effect.succeed(node) : second.load(path) + (node) => node !== undefined ? Effect.succeed(node) : second.load(path) ), (f) => makeOrElse(first.mapInput(f), second.mapInput(f)) ) @@ -398,9 +396,9 @@ function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvid * **Details** * * The `get` callback receives a `Path` and must return - * `Effect, SourceError>`. Return `Option.none()` when the path - * does not exist, `Option.some(node)` when it does, and fail with `SourceError` - * only when the source cannot be read. + * `Effect`. Return `undefined` when the path does + * not exist, a `Node` when it does, and fail with `SourceError` only when the + * source cannot be read. * * Providers created by `make` also implement the path-transformation * capability used by {@link mapInput}, {@link constantCase}, and @@ -409,7 +407,7 @@ function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvid * **Example** (Creating a simple in-memory provider) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const data: Record = { * host: "localhost", @@ -420,13 +418,11 @@ function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvid * const key = path.join(".") * const value = data[key] * return Effect.succeed( - * value !== undefined - * ? Option.some(ConfigProvider.makeValue(value)) - * : Option.none() + * value !== undefined ? ConfigProvider.makeValue(value) : undefined * ) * }) * - * Effect.runSync(provider.load(["host"])) // => Option.some(ConfigProvider.makeValue("localhost")) + * Effect.runSync(provider.load(["host"])) // => ConfigProvider.makeValue("localhost") * ``` * * @see {@link fromEnv} – pre-built provider for environment variables @@ -435,12 +431,12 @@ function makeOrElse(first: ConfigProvider, second: ConfigProvider): ConfigProvid * @category constructors * @since 4.0.0 */ -export function make(get: (path: Path) => Effect.Effect, SourceError>): ConfigProvider { +export function make(get: (path: Path) => Effect.Effect): ConfigProvider { return makeSource(get, identityPath) } /** - * Returns a provider that falls back to `that` when `self` returns `None` + * Returns a provider that falls back to `that` when `self` returns `undefined` * for a path. * * **When to use** @@ -456,13 +452,13 @@ export function make(get: (path: Path) => Effect.Effect, Sou * * **Gotchas** * - * The fallback only runs when the path is not found (`Option.none()`). A + * The fallback only runs when the path is not found (`undefined`). A * `SourceError` from `self` is not caught; it propagates immediately. * * **Example** (Falling back to a default provider) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const envProvider = ConfigProvider.fromEnv({ * env: { HOST: "prod.example.com" } @@ -471,9 +467,9 @@ export function make(get: (path: Path) => Effect.Effect, Sou * * const combined = ConfigProvider.orElse(envProvider, defaults) * - * const host = Option.getOrThrow(Effect.runSync(combined.load(["HOST"]))) - * const port = Option.getOrThrow(Effect.runSync(combined.load(["PORT"]))) - * const values = [host.value, port.value] // => ["prod.example.com", "3000"] + * const host = Effect.runSync(combined.load(["HOST"])) + * const port = Effect.runSync(combined.load(["PORT"])) + * const values = [host?.value, port?.value] // => ["prod.example.com", "3000"] * ``` * * @see {@link layerAdd} – install a fallback provider via a Layer @@ -512,7 +508,7 @@ export const orElse: { * **Example** (Uppercasing path segments) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const provider = ConfigProvider.fromEnv({ * env: { APP_HOST: "localhost" } @@ -524,8 +520,8 @@ export const orElse: { * ) * ) * - * const node = Option.getOrThrow(Effect.runSync(upper.load(["app_host"]))) - * node.value // => "localhost" + * const node = Effect.runSync(upper.load(["app_host"])) + * node?.value // => "localhost" * ``` * * @see {@link constantCase} – a preset that converts to `CONSTANT_CASE` @@ -559,15 +555,15 @@ export const mapInput: { * **Example** (Resolving camelCase keys to env vars) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const provider = ConfigProvider.fromEnv({ * env: { DATABASE_HOST: "localhost" } * }).pipe(ConfigProvider.constantCase) * * // path ["databaseHost"] now resolves to env var DATABASE_HOST - * const node = Option.getOrThrow(Effect.runSync(provider.load(["databaseHost"]))) - * node.value // => "localhost" + * const node = Effect.runSync(provider.load(["databaseHost"])) + * node?.value // => "localhost" * ``` * * @see {@link mapInput} – for arbitrary path transformations @@ -604,7 +600,7 @@ export const constantCase: (self: ConfigProvider) => ConfigProvider = mapInput(( * **Example** (Nesting under a prefix) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const provider = ConfigProvider.fromEnv({ * env: { APP_HOST: "localhost", APP_PORT: "3000" } @@ -612,8 +608,8 @@ export const constantCase: (self: ConfigProvider) => ConfigProvider = mapInput(( * * // Lookups for ["HOST"] now resolve to ["APP", "HOST"] * const scoped = ConfigProvider.nested(provider, "APP") - * const node = Option.getOrThrow(Effect.runSync(scoped.load(["HOST"]))) - * node.value // => "localhost" + * const node = Effect.runSync(scoped.load(["HOST"])) + * node?.value // => "localhost" * ``` * * @see {@link mapInput} – for arbitrary path transformations @@ -685,7 +681,7 @@ export const layer = ( * **Details** * * By default, the new provider acts as a fallback and is consulted only when - * the current provider returns `Option.none()`. Set `asPrimary: true` to make + * the current provider returns `undefined`. Set `asPrimary: true` to make * the new provider the primary source, with the existing one as fallback. * * **Example** (Adding default values) @@ -739,7 +735,7 @@ export const layerAdd = ( * **Details** * * Path traversal follows standard JS rules: string segments index into object - * keys, numeric segments index into arrays. Returns `Option.none()` for any + * keys, numeric segments index into arrays. Returns `undefined` for any * path that cannot be resolved. Never fails with `SourceError`. * * Primitive values (`number`, `boolean`, `bigint`) are stringified via @@ -784,7 +780,7 @@ export function fromUnknown(root: unknown, options?: { readonly preserveEmptyStrings?: boolean | undefined }): ConfigProvider { const preserveEmptyStrings = options?.preserveEmptyStrings === true - return make((path) => Effect.succeed(Option.fromUndefinedOr(nodeAtJson(root, path, preserveEmptyStrings)))) + return make((path) => Effect.succeed(nodeAtJson(root, path, preserveEmptyStrings))) } function nodeAtJson(root: unknown, path: Path, preserveEmptyStrings: boolean): Node | undefined { @@ -902,7 +898,7 @@ export function fromEnv(options?: { const preserveEmptyStrings = options?.preserveEmptyStrings === true const trie = buildEnvTrie(env) - return make((path) => Effect.succeed(Option.fromUndefinedOr(nodeAtEnv(trie, env, path, preserveEmptyStrings)))) + return make((path) => Effect.succeed(nodeAtEnv(trie, env, path, preserveEmptyStrings))) } type EnvTrieNode = { @@ -993,7 +989,7 @@ function trieNodeAt(root: EnvTrieNode, path: Path): EnvTrieNode | undefined { * **Example** (Parsing .env contents) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, Option } from "effect" + * import { ConfigProvider, Effect } from "effect" * * const contents = ` * HOST=localhost @@ -1002,8 +998,8 @@ function trieNodeAt(root: EnvTrieNode, path: Path): EnvTrieNode | undefined { * ` * * const provider = ConfigProvider.fromDotEnvContents(contents) - * const port = Option.getOrThrow(Effect.runSync(provider.load(["PORT"]))) - * port.value // => "3000" + * const port = Effect.runSync(provider.load(["PORT"])) + * port?.value // => "3000" * ``` * * @see {@link fromDotEnv} – loads a `.env` file from disk @@ -1141,7 +1137,7 @@ function searchLast(str: string, rgx: RegExp): number { * **Example** (Loading a .env file) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, FileSystem, Option } from "effect" + * import { ConfigProvider, Effect, FileSystem } from "effect" * * const fileSystem = FileSystem.makeNoop({ * readFileString: () => Effect.succeed("HOST=localhost") @@ -1155,7 +1151,7 @@ function searchLast(str: string, rgx: RegExp): number { * const node = await Effect.runPromise( * Effect.provideService(program, FileSystem.FileSystem, fileSystem) * ) - * Option.getOrThrow(node).value // => "localhost" + * node?.value // => "localhost" * ``` * * @see {@link fromDotEnvContents} – parse a `.env` string directly @@ -1190,7 +1186,7 @@ export const fromDotEnv: (options?: { * Resolution tries a regular file first and returns a `Value` node for * non-empty trimmed file contents. If the file read fails, it tries a directory * and returns a `Record` node with immediate child names as keys. If both fail - * with `NotFound`, it returns `Option.none()`. Other platform failures return + * with `NotFound`, it returns `undefined`. Other platform failures return * `SourceError`. * * Requires `Path` and `FileSystem` in the Effect context. Defaults to root @@ -1204,7 +1200,7 @@ export const fromDotEnv: (options?: { * **Example** (Reading config from a directory) * * ```ts import.meta.vitest - * import { ConfigProvider, Effect, FileSystem, Option, Path } from "effect" + * import { ConfigProvider, Effect, FileSystem, Path } from "effect" * * const fileSystem = FileSystem.makeNoop({ * readFileString: (path) => @@ -1226,7 +1222,7 @@ export const fromDotEnv: (options?: { * Effect.provideService(FileSystem.FileSystem, fileSystem) * ) * ) - * Option.getOrThrow(node).value // => "localhost" + * node?.value // => "localhost" * ``` * * @see {@link fromEnv} – for environment variables @@ -1276,8 +1272,7 @@ export const fromDir: (options?: { message: `Failed to read file at ${platformPath.join(rootPath, ...path.map(String))}`, cause }) - ), - Effect.map(Option.fromUndefinedOr) + ) ) }) }) diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index 1c5e3c56009..6085fa91678 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -1,7 +1,17 @@ -import { describe, it } from "@effect/vitest" -import { deepStrictEqual } from "@effect/vitest/utils" -import { Config, ConfigProvider, Duration, Effect, Option, pipe, Redacted, Result, Schema, SchemaIssue } from "effect" -import * as assert from "node:assert" +import { assert, describe, it } from "@effect/vitest" +import { + Config, + ConfigProvider, + Duration, + Effect, + Option, + pipe, + Redacted, + Result, + Schema, + SchemaIssue, + SchemaTransformation +} from "effect" async function assertSuccess(config: Config.Config, provider: ConfigProvider.ConfigProvider, expected: T) { const r = await config.parse(provider).pipe( @@ -21,29 +31,19 @@ async function assertFailure(config: Config.Config, provider: ConfigProvid } describe("Config", () => { - it("a config is an Effect and can be yielded", () => { - const provider = ConfigProvider.fromEnv({ env: { STRING: "value" } }) - const result = Effect.runSync(Effect.provide( - Config.schema(Schema.Struct({ STRING: Schema.String })), - ConfigProvider.layer(provider) - )) - deepStrictEqual(result, { STRING: "value" }) - }) - - describe("schema", () => { - it("should not leak any information about the value", async () => { - const provider = ConfigProvider.fromUnknown({}) - await assertFailure( - Config.schema(Schema.Redacted(Schema.Literal("secret")), "a"), - provider, - `Invalid data - at ["a"]` + it.effect("uses the current ConfigProvider when yielded as an Effect", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromEnv({ env: { STRING: "value" } }) + const result = yield* Effect.provide( + Config.schema(Schema.Struct({ STRING: Schema.String })), + ConfigProvider.layer(provider) ) - }) - }) + + assert.deepStrictEqual(result, { STRING: "value" }) + })) describe("constructors", () => { - it("fail", async () => { + it("fail creates an always-failing config", async () => { await assertFailure( Config.fail( new Schema.SchemaError(new SchemaIssue.Forbidden(Option.none(), { message: "failure message" })) @@ -53,12 +53,12 @@ describe("Config", () => { ) }) - it("succeed", async () => { + it("succeed creates a provider-independent value", async () => { const provider = ConfigProvider.fromUnknown({}) await assertSuccess(Config.succeed(1), provider, 1) }) - it("string", async () => { + it("string decodes present input and reports absence", async () => { const provider = ConfigProvider.fromUnknown({ a: "value" }) await assertSuccess(Config.string("a"), provider, "value") await assertFailure( @@ -69,7 +69,7 @@ describe("Config", () => { ) }) - it("nonEmptyString", async () => { + it("nonEmptyString rejects preserved empty input", async () => { const provider = ConfigProvider.fromUnknown({ a: "value", b: "" }, { preserveEmptyStrings: true }) await assertSuccess(Config.nonEmptyString("a"), provider, "value") await assertFailure( @@ -80,7 +80,7 @@ describe("Config", () => { ) }) - it("number", async () => { + it("number accepts finite and non-finite numbers", async () => { const provider = ConfigProvider.fromUnknown({ a: "1", c: "c", d: "Infinity" }) await assertSuccess(Config.number("a"), provider, 1) await assertSuccess(Config.number("d"), provider, Infinity) @@ -92,7 +92,7 @@ describe("Config", () => { ) }) - it("finite", async () => { + it("finite rejects invalid and non-finite numbers", async () => { const provider = ConfigProvider.fromUnknown({ a: "1", b: "a", c: "Infinity" }) await assertSuccess(Config.finite("a"), provider, 1) await assertFailure( @@ -109,7 +109,7 @@ describe("Config", () => { ) }) - it("int", async () => { + it("int rejects non-integer numbers", async () => { const provider = ConfigProvider.fromUnknown({ a: "1", b: "1.2" }) await assertSuccess(Config.int("a"), provider, 1) await assertFailure( @@ -120,7 +120,7 @@ describe("Config", () => { ) }) - it("literal", async () => { + it("literal accepts only the configured value", async () => { const provider = ConfigProvider.fromUnknown({ a: "L" }) await assertSuccess(Config.literal("L", "a"), provider, "L") await assertFailure( @@ -131,7 +131,7 @@ describe("Config", () => { ) }) - it("literals", async () => { + it("literals accepts configured string alternatives", async () => { const provider = ConfigProvider.fromUnknown({ a: "production", b: "staging" }) await assertSuccess(Config.literals(["development", "production"], "a"), provider, "production") await assertFailure( @@ -142,7 +142,7 @@ describe("Config", () => { ) }) - it("literals (numbers)", async () => { + it("literals accepts configured number alternatives", async () => { const provider = ConfigProvider.fromUnknown({ a: "1", b: "3" }) await assertSuccess(Config.literals([1, 2], "a"), provider, 1) await assertFailure( @@ -153,7 +153,7 @@ describe("Config", () => { ) }) - it("date", async () => { + it("date rejects invalid dates", async () => { const provider = ConfigProvider.fromUnknown({ a: "2021-01-01", b: "invalid" }) await assertSuccess(Config.date("a"), provider, new Date("2021-01-01")) await assertFailure( @@ -164,7 +164,7 @@ describe("Config", () => { ) }) - it("redacted", async () => { + it("redacted hides values in validation errors", async () => { const provider = ConfigProvider.fromUnknown({ a: "value" }) @@ -178,7 +178,7 @@ describe("Config", () => { ) }) - it("url", async () => { + it("url decodes valid URLs and reports absence", async () => { const provider = ConfigProvider.fromUnknown({ a: "https://example.com" }) @@ -194,7 +194,7 @@ describe("Config", () => { }) describe("combinators", () => { - it("map", async () => { + it("map transforms successful values in data-first and data-last form", async () => { const config = Config.schema(Schema.String) await assertSuccess( @@ -209,7 +209,7 @@ describe("Config", () => { ) }) - it("mapOrFail", async () => { + it("mapOrFail supports effectful validation", async () => { const config = Config.schema(Schema.String) const f = (s: string) => s === "" @@ -232,7 +232,7 @@ describe("Config", () => { ) }) - it("orElse", async () => { + it("orElse evaluates the fallback after absence", async () => { const config = Config.orElse(Config.string("a"), () => Config.finite("b")) await assertSuccess( @@ -247,8 +247,48 @@ describe("Config", () => { ) }) + it.effect("defers user callbacks until the Config Effect is executed", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromUnknown({}) + let mapCalls = 0 + let mapOrFailCalls = 0 + let orElseCalls = 0 + const mapped = Config.succeed(1).pipe( + Config.map((value) => { + mapCalls++ + return value + 1 + }) + ).parse(provider) + const mappedOrFailed = Config.succeed(1).pipe( + Config.mapOrFail((value) => { + mapOrFailCalls++ + return Effect.succeed(value + 1) + }) + ).parse(provider) + const recovered = Config.fail( + new Schema.SchemaError(new SchemaIssue.Forbidden(Option.none(), { message: "failure" })) + ).pipe( + Config.orElse(() => { + orElseCalls++ + return Config.succeed(1) + }) + ).parse(provider) + + assert.strictEqual(mapCalls, 0) + assert.strictEqual(mapOrFailCalls, 0) + assert.strictEqual(orElseCalls, 0) + + yield* mapped + yield* mappedOrFailed + yield* recovered + + assert.strictEqual(mapCalls, 1) + assert.strictEqual(mapOrFailCalls, 1) + assert.strictEqual(orElseCalls, 1) + })) + describe("all", () => { - it("tuple", async () => { + it("combines tuple inputs and preserves positions", async () => { const config = Config.all([Config.nonEmptyString("a"), Config.finite("b")]) await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), ["a", 1]) @@ -266,7 +306,7 @@ describe("Config", () => { ) }) - it("iterable", async () => { + it("combines generic iterables in iteration order", async () => { const config = Config.all(new Set([Config.nonEmptyString("a"), Config.finite("b")])) await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), ["a", 1]) @@ -284,7 +324,7 @@ describe("Config", () => { ) }) - it("struct", async () => { + it("combines named fields and preserves their keys", async () => { const config = Config.all({ a: Config.nonEmptyString("b"), c: Config.finite("d") }) await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), { a: "b", c: 1 }) @@ -304,7 +344,7 @@ describe("Config", () => { }) describe("withDefault", () => { - it("value", async () => { + it("uses the parsed value when present and the default when absent", async () => { const defaultValue = 0 const config = Config.finite("a").pipe(Config.withDefault(defaultValue)) @@ -318,7 +358,7 @@ describe("Config", () => { ) }) - it("redacted", async () => { + it("supports redacted default values", async () => { const defaultValue = Redacted.make("default") const config = Config.redacted("a").pipe(Config.withDefault(defaultValue)) @@ -326,7 +366,7 @@ describe("Config", () => { await assertSuccess(config, ConfigProvider.fromUnknown({}), defaultValue) }) - it("uses default for empty env strings", async () => { + it("treats ignored empty env strings as absent", async () => { const config = Config.string("a").pipe(Config.withDefault("default")) await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), "default") @@ -337,7 +377,7 @@ describe("Config", () => { ) }) - it("uses default for empty env numbers", async () => { + it("validates empty env numbers when they are preserved", async () => { const config = Config.number("a").pipe(Config.withDefault(0)) await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), 0) @@ -351,15 +391,26 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("struct", async () => { + it("defaults wholly absent products and rejects partial products", async () => { const defaultValue = { a: "a", c: 0 } const config = Config.all({ a: Config.nonEmptyString("b"), c: Config.finite("d") }).pipe( Config.withDefault(defaultValue) ) await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), { a: "b", c: 1 }) - await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b" }), defaultValue) - await assertSuccess(config, ConfigProvider.fromUnknown({ d: "1" }), defaultValue) + await assertSuccess(config, ConfigProvider.fromUnknown({}), defaultValue) + await assertFailure( + config, + ConfigProvider.fromUnknown({ b: "b" }), + `Expected string, got undefined + at ["d"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ d: "1" }), + `Expected string, got undefined + at ["b"]` + ) await assertFailure( config, @@ -369,7 +420,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("does not recover from invalid union values", async () => { + it("does not recover from invalid union input", async () => { const config = Config.logLevel("LOG_LEVEL").pipe(Config.withDefault("Info")) await assertSuccess(config, ConfigProvider.fromUnknown({}), "Info") @@ -381,7 +432,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("does not recover from filter failures", async () => { + it("does not recover from schema refinement failures", async () => { const schema = Schema.String.check( Schema.makeFilter((s) => s === "a" ? undefined : new SchemaIssue.InvalidValue(Option.none(), { message: `must be "a"` }) @@ -402,64 +453,98 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("array", async () => { + it("uses the default unless a plain Array schema receives an array representation", async () => { const config = Config.schema(Schema.Array(Schema.String), "a").pipe(Config.withDefault(["default"])) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "value" } }), ["value"]) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "value" } }), ["default"]) await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), ["default"]) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), []) + await assertSuccess( + config, + ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), + ["default"] + ) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "value" } }), ["value"]) await assertSuccess(config, ConfigProvider.fromEnv({ env: {} }), ["default"]) }) - it("schema containers", async () => { - const provider = ConfigProvider.fromEnv({ env: {} }) + it("defaults absent named containers and preserves explicit empty containers", async () => { + const absent = ConfigProvider.fromUnknown({}) await assertSuccess( Config.schema(Schema.Struct({ value: Schema.String }), "a").pipe(Config.withDefault({ value: "default" })), - provider, + absent, { value: "default" } ) await assertSuccess( Config.schema(Schema.Struct({ value: Schema.optionalKey(Schema.String) }), "a").pipe( Config.withDefault({ value: "default" }) ), - provider, + absent, { value: "default" } ) await assertSuccess( Config.schema(Schema.Struct({}), "a").pipe(Config.withDefault({ value: "default" })), - provider, + absent, { value: "default" } ) await assertSuccess( Config.schema(Schema.Record(Schema.String, Schema.String), "a").pipe( Config.withDefault({ value: "default" }) ), - provider, + absent, { value: "default" } ) + await assertSuccess( + Config.schema(Schema.Tuple([]), "a").pipe(Config.withDefault(["default"])), + absent, + ["default"] + ) await assertSuccess( Config.schema(Schema.Tuple([Schema.String]), "a").pipe(Config.withDefault(["default"])), - provider, + absent, ["default"] ) await assertSuccess( Config.schema(Schema.ReadonlySet(Schema.String), "a").pipe(Config.withDefault(new Set(["default"]))), - provider, + absent, new Set(["default"]) ) await assertSuccess( Config.schema(Schema.ReadonlyMap(Schema.String, Schema.String), "a").pipe( Config.withDefault(new Map([["default", "value"]])) ), - provider, + absent, new Map([["default", "value"]]) ) + + await assertSuccess( + Config.schema(Schema.Struct({ value: Schema.optionalKey(Schema.String) }), "a"), + ConfigProvider.fromUnknown({ a: {} }), + {} + ) + await assertSuccess( + Config.schema(Schema.Record(Schema.String, Schema.String), "a"), + ConfigProvider.fromUnknown({ a: {} }), + {} + ) + await assertSuccess( + Config.schema(Schema.Tuple([]), "a"), + ConfigProvider.fromUnknown({ a: [] }), + [] + ) + }) + + it("preserves values successfully decoded from undefined", async () => { + const config = Config.schema(Schema.UndefinedOr(Schema.String), "a").pipe( + Config.withDefault("default") + ) + + await assertSuccess(config, ConfigProvider.fromUnknown({}), undefined) }) }) describe("option", () => { - it("value", async () => { + it("wraps present values and maps absence to None", async () => { const config = Config.finite("a").pipe(Config.option) const stringConfig = Config.string("a").pipe(Config.option) @@ -479,15 +564,31 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("struct", async () => { + it("returns None for absent products and rejects partial products", async () => { const config = Config.all({ a: Config.nonEmptyString("b"), c: Config.finite("d") }).pipe( Config.option ) await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), Option.some({ a: "b", c: 1 })) - await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b" }), Option.none()) - await assertSuccess(config, ConfigProvider.fromUnknown({ d: "1" }), Option.none()) - await assertSuccess(config, ConfigProvider.fromUnknown({ b: "", d: "1" }), Option.none()) + await assertSuccess(config, ConfigProvider.fromUnknown({}), Option.none()) + await assertFailure( + config, + ConfigProvider.fromUnknown({ b: "b" }), + `Expected string, got undefined + at ["d"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ d: "1" }), + `Expected string, got undefined + at ["b"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ b: "", d: "1" }), + `Expected string, got undefined + at ["b"]` + ) await assertFailure( config, @@ -496,11 +597,352 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" at ["b"]` ) }) + + it.effect("wraps successfully decoded undefined in Some", () => + Effect.gen(function*() { + const config = Config.schema(Schema.UndefinedOr(Schema.String), "a").pipe(Config.option) + + assert.deepStrictEqual( + yield* config.parse(ConfigProvider.fromUnknown({})), + Option.some(undefined) + ) + })) + }) + + describe("absence semantics", () => { + describe("schema and all", () => { + const fallback = { host: "fallback", port: 0 } + const schemaConfig = Config.schema( + Schema.Struct({ + host: Schema.String, + port: Schema.Finite + }) + ).pipe(Config.nested("database")) + const allConfig = Config.all({ + host: Config.string("host"), + port: Config.finite("port") + }).pipe(Config.nested("database")) + + it("default wholly absent nested configurations", async () => { + const provider = ConfigProvider.fromUnknown({}) + + await assertSuccess(schemaConfig.pipe(Config.withDefault(fallback)), provider, fallback) + await assertSuccess(allConfig.pipe(Config.withDefault(fallback)), provider, fallback) + }) + + it("distinguish an explicit empty schema container from an absent all group", async () => { + const provider = ConfigProvider.fromUnknown({ database: {} }) + + await assertFailure( + schemaConfig.pipe(Config.withDefault(fallback)), + provider, + `Missing key + at ["database"]["host"]` + ) + await assertSuccess(allConfig.pipe(Config.withDefault(fallback)), provider, fallback) + + await assertFailure( + schemaConfig.pipe(Config.option), + provider, + `Missing key + at ["database"]["host"]` + ) + await assertSuccess(allConfig.pipe(Config.option), provider, Option.none()) + }) + + it("reject partial input for both composition models", async () => { + const provider = ConfigProvider.fromUnknown({ database: { host: "localhost" } }) + + await assertFailure( + schemaConfig.pipe(Config.withDefault(fallback)), + provider, + `Missing key + at ["database"]["port"]` + ) + await assertFailure( + allConfig.pipe(Config.withDefault(fallback)), + provider, + `Expected string, got undefined + at ["database"]["port"]` + ) + }) + + it.effect("does not count successful undefined child values as provider input", () => + Effect.gen(function*() { + const config = Config.all({ + optional: Config.schema(Schema.UndefinedOr(Schema.String), "optional"), + required: Config.string("required") + }) + const fallback = { optional: "fallback", required: "fallback" } + const provider = ConfigProvider.fromUnknown({}) + + assert.deepStrictEqual( + yield* config.pipe(Config.withDefault(fallback)).parse(provider), + fallback + ) + assert.deepStrictEqual( + yield* config.pipe(Config.option).parse(provider), + Option.none() + ) + })) + }) + + it.effect("rejects partial products independently of field order", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromUnknown({ invalid: "not-a-number" }) + const schemaConfigs = [ + Config.schema( + Schema.Struct({ + missing: Schema.String, + invalid: Schema.Finite + }) + ), + Config.schema( + Schema.Struct({ + invalid: Schema.Finite, + missing: Schema.String + }) + ) + ] + const allConfigs = [ + Config.all({ + missing: Config.string("missing"), + invalid: Config.finite("invalid") + }), + Config.all({ + invalid: Config.finite("invalid"), + missing: Config.string("missing") + }) + ] + + for (const config of [...schemaConfigs, ...allConfigs]) { + const error = yield* config.pipe( + Config.withDefault({ missing: "default", invalid: 0 }), + (config) => config.parse(provider), + Effect.flip + ) + assert.ok(error instanceof Config.ConfigError) + } + })) + + it.effect("does not count child defaults as provider input", () => + Effect.gen(function*() { + const fallback = { required: "fallback", defaulted: 0 } + const config = Config.all({ + required: Config.string("required"), + defaulted: Config.int("defaulted").pipe(Config.withDefault(1)) + }).pipe(Config.withDefault(fallback)) + + assert.deepStrictEqual( + yield* config.parse(ConfigProvider.fromUnknown({})), + fallback + ) + assert.deepStrictEqual( + yield* config.parse(ConfigProvider.fromUnknown({ required: "value" })), + { required: "value", defaulted: 1 } + ) + const error = yield* config.parse( + ConfigProvider.fromUnknown({ defaulted: "2" }) + ).pipe(Effect.flip) + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["required"]` + ) + })) + + it.effect("preserves provider input evidence recovered by orElse", () => + Effect.gen(function*() { + const config = Config.all({ + recovered: Config.int("recovered").pipe(Config.orElse(() => Config.succeed(1))), + required: Config.string("required") + }).pipe(Config.withDefault({ recovered: 0, required: "default" })) + const error = yield* config.parse( + ConfigProvider.fromUnknown({ recovered: "invalid" }) + ).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["required"]` + ) + })) + + it.effect("does not invent provider input evidence when orElse recovers absence", () => + Effect.gen(function*() { + const fallback = { recovered: 0, required: "default" } + const config = Config.all({ + recovered: Config.int("recovered").pipe(Config.orElse(() => Config.succeed(1))), + required: Config.string("required") + }).pipe(Config.withDefault(fallback)) + + assert.deepStrictEqual( + yield* config.parse(ConfigProvider.fromUnknown({})), + fallback + ) + })) + + it.effect("does not turn recovered invalid input into absence", () => + Effect.gen(function*() { + const config = Config.int("primary").pipe( + Config.orElse(() => Config.string("fallback")), + Config.withDefault("default") + ) + const error = yield* config.parse( + ConfigProvider.fromUnknown({ primary: "invalid" }) + ).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["fallback"]` + ) + })) + + it.effect("preserves provider input evidence through mapOrFail and orElse", () => + Effect.gen(function*() { + const validationError = new Config.ConfigError( + new Schema.SchemaError(new SchemaIssue.Forbidden(Option.none(), { message: "invalid value" })) + ) + const config = Config.all({ + recovered: Config.string("recovered").pipe( + Config.mapOrFail(() => Effect.fail(validationError)), + Config.orElse(() => Config.succeed("fallback")) + ), + required: Config.string("required") + }).pipe(Config.withDefault({ recovered: "default", required: "default" })) + const error = yield* config.parse( + ConfigProvider.fromUnknown({ recovered: "value" }) + ).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["required"]` + ) + })) + + it.effect("preserves provider input evidence after a descendant source failure", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make((path) => { + if (path.length === 0) { + return Effect.succeed(ConfigProvider.makeRecord(new Set(["value"]))) + } + return path.length === 1 && path[0] === "value" + ? Effect.fail(sourceError) + : Effect.succeed(undefined) + }) + const config = Config.all({ + recovered: Config.schema(Schema.Struct({ value: Schema.String })).pipe( + Config.orElse(() => Config.succeed({ value: "fallback" })) + ), + required: Config.string("required") + }).pipe(Config.withDefault({ recovered: { value: "default" }, required: "default" })) + const error = yield* config.parse(provider).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["required"]` + ) + })) + + it.effect("does not invent provider input evidence after an initial source failure", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make((path) => + path.length === 0 ? Effect.fail(sourceError) : Effect.succeed(undefined) + ) + const fallback = { recovered: { value: "default" }, required: "default" } + const config = Config.all({ + recovered: Config.schema(Schema.Struct({ value: Schema.String })).pipe( + Config.orElse(() => Config.succeed({ value: "fallback" })) + ), + required: Config.string("required") + }).pipe(Config.withDefault(fallback)) + + assert.deepStrictEqual( + yield* config.parse(provider), + fallback + ) + })) + + it.effect("normalizes unavailable scalar representations to absence", () => + Effect.gen(function*() { + const provider = ConfigProvider.make((path) => + Effect.succeed( + path.length === 1 && path[0] === "value" + ? ConfigProvider.makeRecord(new Set()) + : undefined + ) + ) + const config = Config.string("value") + + assert.strictEqual( + yield* config.pipe(Config.withDefault("default")).parse(provider), + "default" + ) + assert.deepStrictEqual( + yield* config.pipe(Config.option).parse(provider), + Option.none() + ) + })) + + it.effect("treats incompatible container representations as absent", () => + Effect.gen(function*() { + const struct = Config.schema( + Schema.Struct({ value: Schema.optionalKey(Schema.String) }), + "value" + ) + const array = Config.schema(Schema.Array(Schema.String), "value") + + assert.deepStrictEqual( + yield* struct.pipe(Config.withDefault({ value: "default" })).parse( + ConfigProvider.fromUnknown({ value: [] }) + ), + { value: "default" } + ) + assert.deepStrictEqual( + yield* array.pipe(Config.option).parse( + ConfigProvider.fromUnknown({ value: {} }) + ), + Option.none() + ) + })) + + it.effect("propagates provider failures", () => + Effect.gen(function*() { + const cause = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make(() => Effect.fail(cause)) + const error = yield* Config.string("a").pipe( + Config.withDefault("fallback"), + (config) => config.parse(provider), + Effect.flip + ) + + assert.strictEqual(error.cause, cause) + })) + + it.effect("rejects present containers with incompatible shapes", () => + Effect.gen(function*() { + const wrongStruct = yield* Config.schema( + Schema.Struct({ value: Schema.optionalKey(Schema.String) }), + "value" + ).parse(ConfigProvider.fromUnknown({ value: [] })).pipe(Effect.flip) + assert.ok(wrongStruct instanceof Config.ConfigError) + + const wrongArray = yield* Config.schema( + Schema.Array(Schema.String), + "value" + ).parse(ConfigProvider.fromUnknown({ value: {} })).pipe(Effect.flip) + assert.ok(wrongArray instanceof Config.ConfigError) + })) }) describe("nested", () => { - describe("fromUnknown", () => { - it("nested", async () => { + describe("with fromUnknown", () => { + it("prefixes a root config", async () => { const config = Config.string().pipe(Config.nested("a")) await assertSuccess( @@ -516,7 +958,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("name + nested", async () => { + it("composes a constructor path with a prefix", async () => { const config = Config.string("a").pipe(Config.nested("b")) await assertSuccess( @@ -532,7 +974,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("name + nested + nested", async () => { + it("composes multiple prefixes from outermost to innermost", async () => { const config = Config.string("a").pipe(Config.nested("b"), Config.nested("c")) await assertSuccess( @@ -548,7 +990,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("all", async () => { + it("prefixes every child of an all product", async () => { const config = Config.all({ host: Config.string("host"), port: Config.number("port") @@ -568,8 +1010,8 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" }) }) - describe("fromEnv", () => { - it("nested", async () => { + describe("with fromEnv", () => { + it("prefixes a root config", async () => { const config = Config.string().pipe(Config.nested("a")) await assertSuccess( @@ -585,7 +1027,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("name + nested", async () => { + it("composes a constructor path with a prefix", async () => { const config = Config.string("a").pipe(Config.nested("b")) await assertSuccess( @@ -601,7 +1043,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("name + nested + nested", async () => { + it("composes multiple prefixes from outermost to innermost", async () => { const config = Config.string("a").pipe(Config.nested("b"), Config.nested("c")) await assertSuccess( @@ -617,7 +1059,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("all", async () => { + it("prefixes every child of an all product", async () => { const config = Config.all({ host: Config.string("host"), port: Config.number("port") @@ -636,7 +1078,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("config nested and provider nested compose lookup but not error paths", async () => { + it("composes Config and provider prefixes without leaking provider paths into errors", async () => { const config = Config.string("host").pipe(Config.nested("database")) const provider = ConfigProvider.fromEnv({ env: { app_database_host: "localhost" } @@ -651,7 +1093,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "" ) }) - it("provider nested over orElse keeps the logical error path", async () => { + it("preserves logical error paths through provider fallback", async () => { const provider = ConfigProvider.fromEnv({ env: { app_port: "abc" } }).pipe( ConfigProvider.orElse(ConfigProvider.fromEnv({ env: {} })), ConfigProvider.nested("app") @@ -670,7 +1112,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "abc" }) describe("unwrap", () => { - it("plain object", async () => { + it("combines a plain record of configs", async () => { const config = Config.unwrap({ a: Config.schema(Schema.String, "a2") }) @@ -678,7 +1120,7 @@ Expected "Infinity" | "-Infinity" | "NaN", got "abc" await assertSuccess(config, ConfigProvider.fromUnknown({ a2: "value" }), { a: "value" }) }) - it("nested", async () => { + it("recursively combines nested records", async () => { const config = Config.unwrap({ a: { b: Config.schema(Schema.String, "b2") @@ -694,831 +1136,1229 @@ Expected "Infinity" | "-Infinity" | "NaN", got "abc" }) }) - describe("Config built-in schemas", () => { - it("Boolean", async () => { - const provider = ConfigProvider.fromUnknown({ - a: "true", - b: "false", - c: "yes", - d: "no", - e: "on", - f: "off", - g: "1", - h: "0", - i: "y", - j: "n", - failure: "value" - }) - - await assertSuccess(Config.boolean("a"), provider, true) - await assertSuccess(Config.boolean("b"), provider, false) - await assertSuccess(Config.boolean("c"), provider, true) - await assertSuccess(Config.boolean("d"), provider, false) - await assertSuccess(Config.boolean("e"), provider, true) - await assertSuccess(Config.boolean("f"), provider, false) - await assertSuccess(Config.boolean("g"), provider, true) - await assertSuccess(Config.boolean("h"), provider, false) - await assertSuccess(Config.boolean("i"), provider, true) - await assertSuccess(Config.boolean("j"), provider, false) + describe("schema", () => { + it("does not expose redacted input in errors", async () => { await assertFailure( - Config.boolean("failure"), - provider, - `Expected "true" | "yes" | "on" | "1" | "y" | "false" | "no" | "off" | "0" | "n", got "value" - at ["failure"]` + Config.schema(Schema.Redacted(Schema.Literal("secret")), "a"), + ConfigProvider.fromUnknown({}), + `Invalid data + at ["a"]` ) }) - it("Duration", async () => { - const provider = ConfigProvider.fromUnknown({ - a: "1000 millis", - b: "1 second", - c: "Infinity", - d: "-Infinity", - failure: "value" - }) + describe("built-in schema-backed constructors", () => { + it("decodes supported boolean spellings", async () => { + const provider = ConfigProvider.fromUnknown({ + a: "true", + b: "false", + c: "yes", + d: "no", + e: "on", + f: "off", + g: "1", + h: "0", + i: "y", + j: "n", + failure: "value" + }) - await assertSuccess(Config.duration("a"), provider, Duration.millis(1000)) - await assertSuccess(Config.duration("b"), provider, Duration.seconds(1)) - await assertSuccess(Config.duration("c"), provider, Duration.infinity) - await assertSuccess(Config.duration("d"), provider, Duration.negativeInfinity) - await assertFailure( - Config.duration("failure"), - provider, - `Invalid Duration string: value + await assertSuccess(Config.boolean("a"), provider, true) + await assertSuccess(Config.boolean("b"), provider, false) + await assertSuccess(Config.boolean("c"), provider, true) + await assertSuccess(Config.boolean("d"), provider, false) + await assertSuccess(Config.boolean("e"), provider, true) + await assertSuccess(Config.boolean("f"), provider, false) + await assertSuccess(Config.boolean("g"), provider, true) + await assertSuccess(Config.boolean("h"), provider, false) + await assertSuccess(Config.boolean("i"), provider, true) + await assertSuccess(Config.boolean("j"), provider, false) + await assertFailure( + Config.boolean("failure"), + provider, + `Expected "true" | "yes" | "on" | "1" | "y" | "false" | "no" | "off" | "0" | "n", got "value" at ["failure"]` - ) - }) - - it("Port", async () => { - const provider = ConfigProvider.fromUnknown({ - a: "8080", - failure: "-1" + ) }) - await assertSuccess(Config.port("a"), provider, 8080) - await assertFailure( - Config.port("failure"), - provider, - `Expected a value between 1 and 65535, got -1 + it("decodes durations including infinities", async () => { + const provider = ConfigProvider.fromUnknown({ + a: "1000 millis", + b: "1 second", + c: "Infinity", + d: "-Infinity", + failure: "value" + }) + + await assertSuccess(Config.duration("a"), provider, Duration.millis(1000)) + await assertSuccess(Config.duration("b"), provider, Duration.seconds(1)) + await assertSuccess(Config.duration("c"), provider, Duration.infinity) + await assertSuccess(Config.duration("d"), provider, Duration.negativeInfinity) + await assertFailure( + Config.duration("failure"), + provider, + `Invalid Duration string: value at ["failure"]` - ) - }) + ) + }) - it("LogLevel / logLevel", async () => { - const provider = ConfigProvider.fromUnknown({ - a: "Info", - failure_1: "info", - failure_2: "value" + it("validates port ranges", async () => { + const provider = ConfigProvider.fromUnknown({ + a: "8080", + failure: "-1" + }) + + await assertSuccess(Config.port("a"), provider, 8080) + await assertFailure( + Config.port("failure"), + provider, + `Expected a value between 1 and 65535, got -1 + at ["failure"]` + ) }) - await assertSuccess(Config.logLevel("a"), provider, "Info") - await assertFailure( - Config.logLevel("failure_1"), - provider, - `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None", got "info" + it("validates log-level literals", async () => { + const provider = ConfigProvider.fromUnknown({ + a: "Info", + failure_1: "info", + failure_2: "value" + }) + + await assertSuccess(Config.logLevel("a"), provider, "Info") + await assertFailure( + Config.logLevel("failure_1"), + provider, + `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None", got "info" at ["failure_1"]` - ) - await assertFailure( - Config.logLevel("failure_2"), - provider, - `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None", got "value" + ) + await assertFailure( + Config.logLevel("failure_2"), + provider, + `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None", got "value" at ["failure_2"]` - ) - }) + ) + }) - describe("Record", () => { - it("from record", async () => { - const schema = Config.Record(Schema.String, Schema.String) - const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") + describe("Record", () => { + it("decodes object input", async () => { + const schema = Config.Record(Schema.String, Schema.String) + const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") - await assertSuccess( - config, - ConfigProvider.fromUnknown({ - OTEL_RESOURCE_ATTRIBUTES: { + await assertSuccess( + config, + ConfigProvider.fromUnknown({ + OTEL_RESOURCE_ATTRIBUTES: { + "service.name": "my-service", + "service.version": "1.0.0", + "custom.attribute": "value" + } + }), + { "service.name": "my-service", "service.version": "1.0.0", "custom.attribute": "value" } - }), - { - "service.name": "my-service", - "service.version": "1.0.0", - "custom.attribute": "value" - } - ) - }) + ) + }) - it("from string", async () => { - const schema = Config.Record(Schema.String, Schema.String) - const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") + it("decodes separated string input", async () => { + const schema = Config.Record(Schema.String, Schema.String) + const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") - await assertSuccess( - config, - ConfigProvider.fromEnv({ - env: { - OTEL_RESOURCE_ATTRIBUTES: "service.name=my-service,service.version=1.0.0,custom.attribute=value" + await assertSuccess( + config, + ConfigProvider.fromEnv({ + env: { + OTEL_RESOURCE_ATTRIBUTES: "service.name=my-service,service.version=1.0.0,custom.attribute=value" + } + }), + { + "service.name": "my-service", + "service.version": "1.0.0", + "custom.attribute": "value" } - }), - { - "service.name": "my-service", - "service.version": "1.0.0", - "custom.attribute": "value" - } - ) - }) + ) + }) - it("options", async () => { - const schema = Config.Record(Schema.String, Schema.String, { separator: "&", keyValueSeparator: "==" }) - const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") + it("supports custom separators", async () => { + const schema = Config.Record(Schema.String, Schema.String, { separator: "&", keyValueSeparator: "==" }) + const config = Config.schema(schema, "OTEL_RESOURCE_ATTRIBUTES") - await assertSuccess( - config, - ConfigProvider.fromEnv({ - env: { - OTEL_RESOURCE_ATTRIBUTES: "service.name==my-service&service.version==1.0.0&custom.attribute==value" + await assertSuccess( + config, + ConfigProvider.fromEnv({ + env: { + OTEL_RESOURCE_ATTRIBUTES: "service.name==my-service&service.version==1.0.0&custom.attribute==value" + } + }), + { + "service.name": "my-service", + "service.version": "1.0.0", + "custom.attribute": "value" } - }), - { - "service.name": "my-service", - "service.version": "1.0.0", - "custom.attribute": "value" - } - ) + ) + }) }) }) - }) - describe("fromEnv", () => { - it("path argument", async () => { - await assertSuccess( - Config.schema(Schema.String, "a"), - ConfigProvider.fromEnv({ env: { a: "value" } }), - "value" - ) - await assertSuccess( - Config.schema(Schema.String, ["a", "b"]), - ConfigProvider.fromEnv({ env: { "a_b": "value" } }), - "value" - ) - await assertSuccess( - Config.schema(Schema.UndefinedOr(Schema.String)), - ConfigProvider.fromEnv({ env: {} }), - undefined - ) - await assertSuccess( - Config.schema(Schema.UndefinedOr(Schema.String), "a"), - ConfigProvider.fromEnv({ env: {} }), - undefined - ) - }) + describe("materialization", () => { + describe("Encoded shapes", () => { + const scalarToStruct = Schema.String.pipe( + Schema.decodeTo( + Schema.Struct({ value: Schema.String }), + SchemaTransformation.transform({ + decode: (value) => ({ value }), + encode: ({ value }) => value + }) + ) + ) + const structToScalar = Schema.Struct({ value: Schema.String }).pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: ({ value }) => value, + encode: (value) => ({ value }) + }) + ) + ) - describe("leafs and containers", () => { - it("node can be both leaf and object", async () => { - const schema = Schema.Struct({ a: Schema.Number }) - const config = Config.schema(schema) + it.effect("loads the encoded shape when it differs from the decoded shape", () => + Effect.gen(function*() { + assert.deepStrictEqual( + yield* Config.schema(scalarToStruct, "config").parse( + ConfigProvider.fromUnknown({ config: "value" }) + ), + { value: "value" } + ) + assert.strictEqual( + yield* Config.schema(structToScalar, "config").parse( + ConfigProvider.fromUnknown({ config: { value: "value" } }) + ), + "value" + ) + })) + + it.effect("loads encoded shapes recursively inside objects and arrays", () => + Effect.gen(function*() { + const config = Config.schema( + Schema.Struct({ + fromScalar: scalarToStruct, + fromStruct: structToScalar, + items: Schema.Array(scalarToStruct) + }) + ) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2" } }), { a: 1 }) - }) + assert.deepStrictEqual( + yield* config.parse( + ConfigProvider.fromUnknown({ + fromScalar: "one", + fromStruct: { value: "two" }, + items: ["three"] + }) + ), + { + fromScalar: { value: "one" }, + fromStruct: "two", + items: [{ value: "three" }] + } + ) + })) - it("node can be both leaf and array", async () => { - const schema = Schema.Struct({ a: Schema.Number }) - const config = Config.schema(schema) + it.effect("loads every union member from its encoded shape", () => + Effect.gen(function*() { + const config = Config.schema( + Schema.Union([scalarToStruct, structToScalar]), + "config" + ) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_0": "2" } }), { a: 1 }) + assert.deepStrictEqual( + yield* config.parse(ConfigProvider.fromUnknown({ config: "scalar" })), + { value: "scalar" } + ) + assert.strictEqual( + yield* config.parse( + ConfigProvider.fromUnknown({ config: { value: "struct" } }) + ), + "struct" + ) + })) }) - it("if a node can be both object and array, it should be an object", async () => { - const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.Number }) }) - const config = Config.schema(schema) + describe("Objects", () => { + it.effect("loads explicit properties even when only a fallback provider contains the child", () => + Effect.gen(function*() { + const primary = ConfigProvider.make((path) => + Effect.succeed( + path.length === 0 + ? ConfigProvider.makeRecord(new Set()) + : undefined + ) + ) + const fallback = ConfigProvider.make((path) => + Effect.succeed( + path.length === 1 && path[0] === "host" + ? ConfigProvider.makeValue("localhost") + : undefined + ) + ) + const provider = ConfigProvider.orElse(primary, fallback) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2", "a_0": "3" } }), { - a: { b: 2 } - }) + assert.deepStrictEqual( + yield* Config.schema(Schema.Struct({ host: Schema.String })).parse(provider), + { host: "localhost" } + ) + })) + + it.effect("does not load advertised keys that are unrelated to the schema", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "unrelated key was loaded" }) + const provider = ConfigProvider.make((path) => { + if (path.length === 0) { + return Effect.succeed(ConfigProvider.makeRecord(new Set(["wanted", "unrelated"]))) + } + if (path[0] === "wanted") { + return Effect.succeed(ConfigProvider.makeValue("value")) + } + return Effect.fail(sourceError) + }) + + assert.deepStrictEqual( + yield* Config.schema(Schema.Struct({ wanted: Schema.String })).parse(provider), + { wanted: "value" } + ) + })) + + it.effect("loads advertised keys only when they match an index signature", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "non-matching key was loaded" }) + const provider = ConfigProvider.make((path) => { + if (path.length === 0) { + return Effect.succeed(ConfigProvider.makeRecord(new Set(["wanted", "unrelated"]))) + } + if (path[0] === "wanted") { + return Effect.succeed(ConfigProvider.makeValue("value")) + } + return Effect.fail(sourceError) + }) + + assert.deepStrictEqual( + yield* Config.schema(Schema.Record(Schema.Literal("wanted"), Schema.String)).parse(provider), + { wanted: "value" } + ) + })) + + it.effect("leaves separated record parsing to the explicit Config.Record schema", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromEnv({ + env: { + values: "first=1,second=2" + } + }) + + assert.deepStrictEqual( + yield* Config.schema(Config.Record(Schema.String, Schema.Finite), "values").parse(provider), + { first: 1, second: 2 } + ) + })) }) - }) - it("Null", async () => { - const schema = Schema.Null - const config = Config.schema(schema, "a") + describe("Arrays", () => { + it.effect("preserves missing array positions as undefined values", () => + Effect.gen(function*() { + const provider = ConfigProvider.make((path) => { + if (path.length === 0) { + return Effect.succeed(ConfigProvider.makeArray(2)) + } + return Effect.succeed( + path[0] === 0 + ? ConfigProvider.makeValue("value") + : undefined + ) + }) + + assert.deepStrictEqual( + yield* Config.schema(Schema.Array(Schema.UndefinedOr(Schema.String))).parse(provider), + ["value", undefined] + ) + })) + + it.effect("leaves scalar-to-array parsing to the explicit Config.Array schema", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromEnv({ + env: { + values: "1,2", + values_0: "3" + } + }) + + assert.deepStrictEqual( + yield* Config.schema(Config.Array(Schema.Finite), "values").parse(provider), + [1, 2] + ) + assert.deepStrictEqual( + yield* Config.schema(Schema.Array(Schema.Finite), "values").parse(provider), + [3] + ) + })) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "null" } }), null) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected "null", got undefined - at ["a"]` - ) - }) + describe("Opaque schemas", () => { + const unsupported = [ + ["Any", Schema.Any], + ["Unknown", Schema.Unknown], + ["ObjectKeyword", Schema.ObjectKeyword], + ["Json", Schema.Json], + ["MutableJson", Schema.MutableJson] + ] as const + + for (const [name, schema] of unsupported) { + it(`rejects Schema.${name}`, () => { + assert.throws( + () => Config.schema(schema), + /Config\.schema does not support opaque StringTree encodings/ + ) + }) + } - it("String", async () => { - const schema = Schema.String - const config = Config.schema(schema, "a") + it("rejects opaque shapes nested in objects", () => { + assert.throws( + () => Config.schema(Schema.Struct({ value: Schema.Unknown })), + /Config\.schema does not support opaque StringTree encodings/ + ) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected string, got undefined - at ["a"]` - ) - }) + it("rejects opaque union members", () => { + assert.throws( + () => Config.schema(Schema.Union([Schema.String, Schema.Unknown])), + /Config\.schema does not support opaque StringTree encodings/ + ) + }) - it("Number", async () => { - const schema = Schema.Number - const config = Config.schema(schema, "a") + it("rejects opaque shapes behind suspensions", () => { + assert.throws( + () => Config.schema(Schema.suspend(() => Schema.Unknown)), + /Config\.schema does not support opaque StringTree encodings/ + ) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected string | "Infinity" | "-Infinity" | "NaN", got undefined - at ["a"]` - ) - }) + it.effect("supports declarations with concrete StringTree encodings", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromUnknown({ value: "https://example.com" }) + const value = yield* Config.schema(Schema.URL, "value").parse(provider) - it("Finite", async () => { - const schema = Schema.Finite - const config = Config.schema(schema, "a") + assert.strictEqual(value.href, "https://example.com/") + })) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected string, got undefined - at ["a"]` - ) - }) + it.effect("supports arbitrary JSON encoded in a scalar string", () => + Effect.gen(function*() { + const provider = ConfigProvider.fromUnknown({ value: `{"nested":[1,true]}` }) + const value = yield* Config.schema(Schema.fromJsonString(Schema.Json), "value").parse(provider) - it("Int", async () => { - const schema = Schema.Int - const config = Config.schema(schema, "a") + assert.deepStrictEqual(value, { nested: [1, true] }) + })) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected string, got undefined - at ["a"]` - ) - }) + describe("Union", () => { + it("materializes each member independently before applying first-match semantics", async () => { + const config = Config.schema( + Schema.Union([ + Schema.Struct({ child: Schema.String }), + Schema.String + ]), + "value" + ) + const provider = ConfigProvider.fromEnv({ + env: { + value: "scalar", + value_child: "object" + } + }) - it("Boolean", async () => { - const schema = Schema.Boolean - const config = Config.schema(schema, "a") + await assertSuccess(config, provider, { child: "object" }) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "true" } }), true) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "false" } }), false) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Expected "true" | "false", got undefined - at ["a"]` - ) - }) + it("preserves first-match semantics when the scalar member is declared first", async () => { + const config = Config.schema( + Schema.Union([ + Schema.String, + Schema.Struct({ child: Schema.String }) + ]), + "value" + ) + const provider = ConfigProvider.fromEnv({ + env: { + value: "scalar", + value_child: "object" + } + }) - describe("Struct", () => { - it("required properties", async () => { - const schema = Schema.Struct({ a: Schema.Number }) - const config = Config.schema(schema) + await assertSuccess(config, provider, "scalar") + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) - }) + it("reports oneOf ambiguity without exposing the internal cursor", async () => { + const config = Config.schema( + Schema.Union([ + Schema.Struct({ child: Schema.String }), + Schema.String + ], { mode: "oneOf" }), + ["database", "value"] + ) + const provider = ConfigProvider.fromEnv({ + env: { + database_value: "scalar", + database_value_child: "object" + } + }) - it("optionalKey properties", async () => { - const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) }) - const config = Config.schema(schema) + await assertFailure( + config, + provider, + `Expected exactly one member to match the input + at ["database"]["value"]` + ) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: {} }), {}) + it.effect("counts input available to any member when composing with Config.all", () => + Effect.gen(function*() { + const config = Config.all({ + selected: Config.schema( + Schema.Union([ + Schema.Undefined, + Schema.Struct({ child: Schema.String }) + ]), + "value" + ), + required: Config.string("required") + }).pipe( + Config.withDefault({ + selected: undefined, + required: "default" + }) + ) + const provider = ConfigProvider.fromEnv({ + env: { + value_child: "present" + } + }) + + const error = yield* config.parse(provider).pipe(Effect.flip) + assert.strictEqual( + error.cause.message, + `Expected string, got undefined + at ["required"]` + ) + })) + + it.effect("applies checks attached to the original union", () => + Effect.gen(function*() { + const schema = Schema.Union([ + Schema.Struct({ child: Schema.String }), + Schema.String + ]).check( + Schema.makeFilter((value) => + typeof value === "string" + ? new SchemaIssue.InvalidValue(Option.none(), { message: "union check failed" }) + : undefined + ) + ) + const error = yield* Config.schema(schema, "value").parse( + ConfigProvider.fromUnknown({ value: "scalar" }) + ).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `union check failed + at ["value"]` + ) + })) + + it.effect("propagates SourceError defects instead of trying another member", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make((path) => { + if (path.length === 1 && path[0] === "value") { + return Effect.succeed(ConfigProvider.makeRecord(new Set(["child"]), "scalar")) + } + return Effect.fail(sourceError) + }) + const config = Config.schema( + Schema.Union([ + Schema.Struct({ child: Schema.String }), + Schema.String + ]), + "value" + ) + + const error = yield* config.parse(provider).pipe(Effect.flip) + assert.strictEqual(error.cause, sourceError) + })) }) + }) - it("optional properties", async () => { - const config = Config.schema( - Schema.Struct({ a: Schema.optional(Schema.Number) }) + describe("fromEnv provider", () => { + it("loads root, flat, and nested paths", async () => { + await assertSuccess( + Config.schema(Schema.String, "a"), + ConfigProvider.fromEnv({ env: { a: "value" } }), + "value" + ) + await assertSuccess( + Config.schema(Schema.String, ["a", "b"]), + ConfigProvider.fromEnv({ env: { "a_b": "value" } }), + "value" + ) + await assertSuccess( + Config.schema(Schema.UndefinedOr(Schema.String)), + ConfigProvider.fromEnv({ env: {} }), + undefined + ) + await assertSuccess( + Config.schema(Schema.UndefinedOr(Schema.String), "a"), + ConfigProvider.fromEnv({ env: {} }), + undefined ) - - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: {} }), {}) }) - it("literal property", async () => { - const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) }) - const config = Config.schema(schema) + describe("node precedence", () => { + it("uses a co-located scalar instead of object children for a leaf schema", async () => { + const schema = Schema.Struct({ a: Schema.Number }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2" } }), { a: 1 }) + }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "b" } }), { a: "b" }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "c" } }), { a: "c" }) + it("uses a co-located scalar instead of array children for a leaf schema", async () => { + const schema = Schema.Struct({ a: Schema.Number }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_0": "2" } }), { a: 1 }) + }) + + it("prefers object children when a node can be both object and array", async () => { + const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.Number }) }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2", "a_0": "3" } }), { + a: { b: 2 } + }) + }) }) - it("array property", async () => { - const schema = Schema.Struct({ a: Schema.Array(Schema.Number) }) - const config = Config.schema(schema) + it("decodes Null", async () => { + const schema = Schema.Null + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), { a: [] }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: [1] }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1" } }), { a: [1] }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", a_0: "2" } }), { a: [1] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "null" } }), null) await assertFailure( config, ConfigProvider.fromEnv({ env: {} }), - `Missing key + `Expected "null", got undefined at ["a"]` ) }) - }) - it("Record(String, Finite)", async () => { - const schema = Schema.Record(Schema.String, Schema.Finite) - const config = Config.schema(schema) + it("decodes String and reports absence", async () => { + const schema = Schema.String + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", b: "2" } }), { a: 1, b: 2 }) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: { a: "1", b: "value" } }), - `Expected a string representing a finite number, got "value" - at ["b"]` - ) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") + await assertFailure( + config, + ConfigProvider.fromEnv({ env: {} }), + `Expected string, got undefined + at ["a"]` + ) + }) - describe("Tuple", () => { - it("empty", async () => { - const schema = Schema.Struct({ a: Schema.Tuple([]) }) - const config = Config.schema(schema) + it("decodes Number and reports absence", async () => { + const schema = Schema.Number + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), { a: [] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: {} }), + `Expected string | "Infinity" | "-Infinity" | "NaN", got undefined + at ["a"]` + ) }) - it("ensure array", async () => { - const schema = Schema.Struct({ a: Schema.Tuple([Schema.Number]) }) - const config = Config.schema(schema) + it("decodes Finite and reports absence", async () => { + const schema = Schema.Finite + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: [1] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: {} }), + `Expected string, got undefined + at ["a"]` + ) }) - it("required elements", async () => { - const schema = Schema.Struct({ a: Schema.Tuple([Schema.String, Schema.Finite]) }) - const config = Config.schema(schema) + it("decodes Int and reports absence", async () => { + const schema = Schema.Int + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "2" } }), { a: ["a", 2] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) await assertFailure( config, - ConfigProvider.fromEnv({ env: { a: "a" } }), - `Missing key - at ["a"][1]` + ConfigProvider.fromEnv({ env: {} }), + `Expected string, got undefined + at ["a"]` ) + }) + + it("decodes Boolean and reports absence", async () => { + const schema = Schema.Boolean + const config = Config.schema(schema, "a") + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "true" } }), true) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "false" } }), false) await assertFailure( config, - ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "value" } }), - `Expected a string representing a finite number, got "value" - at ["a"][1]` + ConfigProvider.fromEnv({ env: {} }), + `Expected "true" | "false", got undefined + at ["a"]` ) }) - }) - it("Array(Finite)", async () => { - const schema = Schema.Struct({ a: Schema.Array(Schema.Finite) }) - const config = Config.schema(schema) + describe("Struct", () => { + it("decodes required properties", async () => { + const schema = Schema.Struct({ a: Schema.Number }) + const config = Config.schema(schema) - // ensure array - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1,2,3" } }), { a: [1, 2, 3] }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] }) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: { a: "a", a_0: "1" } }), - `Expected a string representing a finite number, got "a" - at ["a"][0]` - ) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: { a_0: "1", a_2: "2" } }), - `Expected string, got undefined - at ["a"][1]` - ) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "value" } }), - `Expected a string representing a finite number, got "value" - at ["a"][1]` - ) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) + }) - describe("Union", () => { - describe("Literals", () => { - it("string", async () => { - const schema = Schema.Struct({ a: Schema.Literals(["a", "b"]) }) + it("omits absent optionalKey properties", async () => { + const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { unrelated: "value" } }), {}) + }) + + it("omits absent optional properties", async () => { + const config = Config.schema( + Schema.Struct({ a: Schema.optional(Schema.Number) }) + ) + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { unrelated: "value" } }), {}) + }) + + it("decodes literal properties", async () => { + const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) }) const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "b" } }), { a: "b" }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "c" } }), { a: "c" }) }) - }) - it("inclusive", async () => { - const schema = Schema.Union([ - Schema.Struct({ a: Schema.String }), - Schema.Struct({ b: Schema.Number }) - ]) - const config = Config.schema(schema) + it("decodes indexed array properties without treating co-located scalars as arrays", async () => { + const schema = Schema.Struct({ a: Schema.Array(Schema.Number) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }), { a: "a" }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), + `Expected array, got undefined + at ["a"]` + ) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "1" } }), + `Expected array, got undefined + at ["a"]` + ) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1" } }), { a: [1] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", a_0: "2" } }), { a: [2] }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: {} }), + `Expected object, got undefined` + ) + }) }) - it("exclusive", async () => { - const schema = Schema.Union([ - Schema.Struct({ a: Schema.String }), - Schema.Struct({ b: Schema.Number }) - ], { mode: "oneOf" }) + it("decodes and validates Record values", async () => { + const schema = Schema.Record(Schema.String, Schema.Finite) const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", b: "2" } }), { a: 1, b: 2 }) await assertFailure( config, - ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }), - `Expected exactly one member to match the input {"a":"a","b":"1"}` + ConfigProvider.fromEnv({ env: { a: "1", b: "value" } }), + `Expected a string representing a finite number, got "value" + at ["b"]` ) }) - it("number | string", async () => { - const schema = Schema.Union([Schema.Number, Schema.String]) - const config = Config.schema(schema, "a") - - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") - }) - - it("string | number", async () => { - const schema = Schema.Union([Schema.String, Schema.Number]) - const config = Config.schema(schema, "a") - - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") - }) - }) - - it("Suspend", async () => { - interface A { - readonly a: string - readonly as: ReadonlyArray - } - const schema = Schema.Struct({ - a: Schema.String, - as: Schema.Array(Schema.suspend((): Schema.Codec => schema)) - }) - const config = Config.schema(schema) - - await assertSuccess( - config, - ConfigProvider.fromEnv({ env: { a: "1", as: "" }, preserveEmptyStrings: true }), - { a: "1", as: [] } - ) - await assertSuccess( - config, - ConfigProvider.fromEnv({ env: { a: "1", as_0_a: "2", as_0_as: "" }, preserveEmptyStrings: true }), - { - a: "1", - as: [{ a: "2", as: [] }] - } - ) - }) - - it("Redacted(Int)", async () => { - const schema = Schema.Redacted(Schema.Int) - const config = Config.schema(schema, "a") + describe("Tuple", () => { + it("rejects a scalar where an empty tuple is expected", async () => { + const schema = Schema.Struct({ a: Schema.Tuple([]) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), Redacted.make(1)) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: {} }), - `Invalid data - at ["a"]` - ) - await assertFailure( - config, - ConfigProvider.fromEnv({ env: { a: "1.1" } }), - `Invalid data + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }), + `Expected array, got undefined at ["a"]` - ) - }) - }) - - describe("fromUnknown", () => { - it("path argument", async () => { - await assertSuccess( - Config.schema(Schema.String, []), - ConfigProvider.fromUnknown("value"), - "value" - ) - await assertSuccess( - Config.schema(Schema.String, "a"), - ConfigProvider.fromUnknown({ a: "value" }), - "value" - ) - await assertSuccess( - Config.schema(Schema.String, ["a", "b"]), - ConfigProvider.fromUnknown({ a: { b: "value" } }), - "value" - ) - }) + ) + }) - it("Undefined", async () => { - const schema = Schema.Undefined - const config = Config.schema(schema) + it("rejects scalar tuple input", async () => { + const schema = Schema.Struct({ a: Schema.Tuple([Schema.Number]) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown(undefined), undefined) - await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected undefined, got "a"`) - }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "1" } }), + `Expected array, got undefined + at ["a"]` + ) + }) - it("Null", async () => { - const schema = Schema.Null - const config = Config.schema(schema) + it("requires and validates every tuple element", async () => { + const schema = Schema.Struct({ a: Schema.Tuple([Schema.String, Schema.Finite]) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("null"), null) - await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "null", got "a"`) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "2" } }), { a: ["a", 2] }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "a" } }), + `Expected array, got undefined + at ["a"]` + ) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "value" } }), + `Expected a string representing a finite number, got "value" + at ["a"][1]` + ) + }) + }) - it("String", async () => { - const schema = Schema.String - const config = Config.schema(schema) + it("decodes indexed Array input and rejects scalar input", async () => { + const schema = Schema.Struct({ a: Schema.Array(Schema.Finite) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("value"), "value") - await assertFailure(config, ConfigProvider.fromUnknown({}), `Expected string, got undefined`) - }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "1,2,3" } }), + `Expected array, got undefined + at ["a"]` + ) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a", a_0: "1" } }), { a: [1] }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a_0: "1", a_2: "2" } }), + `Expected string, got undefined + at ["a"][1]` + ) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "value" } }), + `Expected a string representing a finite number, got "value" + at ["a"][1]` + ) + }) - it("Number", async () => { - const schema = Schema.Number - const config = Config.schema(schema) + describe("Union", () => { + it("decodes literal unions", async () => { + const schema = Schema.Struct({ a: Schema.Literals(["a", "b"]) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) - await assertFailure( - config, - ConfigProvider.fromUnknown("a"), - `Expected a string representing a finite number, got "a" -Expected "Infinity" | "-Infinity" | "NaN", got "a"` - ) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "b" } }), { a: "b" }) + }) - it("Finite", async () => { - const schema = Schema.Finite - const config = Config.schema(schema) + it("uses first-match semantics by default", async () => { + const schema = Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ]) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) - await assertFailure( - config, - ConfigProvider.fromUnknown("a"), - `Expected a string representing a finite number, got "a"` - ) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }), { a: "a" }) + }) - it("Int", async () => { - const schema = Schema.Int - const config = Config.schema(schema) + it("enforces exactly one match in oneOf mode", async () => { + const schema = Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ], { mode: "oneOf" }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) - await assertFailure( - config, - ConfigProvider.fromUnknown("a"), - `Expected a string representing a finite number, got "a"` - ) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 }) + await assertFailure( + config, + ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }), + `Expected exactly one member to match the input ` + ) + }) - it("Boolean", async () => { - const schema = Schema.Boolean - const config = Config.schema(schema) + it("decodes Number before String", async () => { + const schema = Schema.Union([Schema.Number, Schema.String]) + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromUnknown("true"), true) - await assertSuccess(config, ConfigProvider.fromUnknown("false"), false) - await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "true" | "false", got "a"`) - }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") + }) - describe("Struct", () => { - it("required properties", async () => { - const schema = Schema.Struct({ a: Schema.Finite }) - const config = Config.schema(schema) + it("still decodes numeric input when String is listed first", async () => { + const schema = Schema.Union([Schema.String, Schema.Number]) + const config = Config.schema(schema, "a") - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1) + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a") + }) + }) + + it("redacts Int validation errors", async () => { + const schema = Schema.Redacted(Schema.Int) + const config = Config.schema(schema, "a") + + await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), Redacted.make(1)) await assertFailure( config, - ConfigProvider.fromUnknown({}), - `Missing key + ConfigProvider.fromEnv({ env: {} }), + `Invalid data at ["a"]` ) await assertFailure( config, - ConfigProvider.fromUnknown({ a: "value" }), - `Expected a string representing a finite number, got "value" + ConfigProvider.fromEnv({ env: { a: "1.1" } }), + `Invalid data at ["a"]` ) }) + }) + + describe("fromUnknown provider", () => { + it("loads root, flat, and nested paths", async () => { + await assertSuccess( + Config.schema(Schema.String, []), + ConfigProvider.fromUnknown("value"), + "value" + ) + await assertSuccess( + Config.schema(Schema.String, "a"), + ConfigProvider.fromUnknown({ a: "value" }), + "value" + ) + await assertSuccess( + Config.schema(Schema.String, ["a", "b"]), + ConfigProvider.fromUnknown({ a: { b: "value" } }), + "value" + ) + }) - it("optionalKey properties", async () => { - const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) }) + it("decodes Undefined", async () => { + const schema = Schema.Undefined const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromUnknown({}), {}) + await assertSuccess(config, ConfigProvider.fromUnknown(undefined), undefined) + await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected undefined, got "a"`) }) - it("optional properties", async () => { - const config = Config.schema( - Schema.Struct({ a: Schema.optional(Schema.Number) }) - ) + it("decodes Null", async () => { + const schema = Schema.Null + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromUnknown({}), {}) + await assertSuccess(config, ConfigProvider.fromUnknown("null"), null) + await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "null", got "a"`) }) - it("literal property", async () => { - const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) }) + it("decodes String and rejects object input", async () => { + const schema = Schema.String const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "b" }), { a: "b" }) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "c" }), { a: "c" }) + await assertSuccess(config, ConfigProvider.fromUnknown("value"), "value") + await assertFailure(config, ConfigProvider.fromUnknown({}), `Expected string, got undefined`) }) - it("array property", async () => { - const schema = Schema.Struct({ a: Schema.Array(Schema.Number) }) + it("decodes Number and rejects invalid input", async () => { + const schema = Schema.Number const config = Config.schema(schema) + await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) await assertFailure( config, - ConfigProvider.fromUnknown({ a: "" }), - `Missing key - at ["a"]` + ConfigProvider.fromUnknown("a"), + `Expected a string representing a finite number, got "a" +Expected "Infinity" | "-Infinity" | "NaN", got "a"` ) - await assertSuccess( + }) + + it("decodes Finite and rejects invalid input", async () => { + const schema = Schema.Finite + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) + await assertFailure( config, - ConfigProvider.fromUnknown({ a: "" }, { preserveEmptyStrings: true }), - { a: [] } + ConfigProvider.fromUnknown("a"), + `Expected a string representing a finite number, got "a"` ) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: [1] }) }) - }) - it("Record(String, Finite)", async () => { - const schema = Schema.Record(Schema.String, Schema.Finite) - const config = Config.schema(schema) + it("decodes Int and rejects invalid input", async () => { + const schema = Schema.Int + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", b: "2" }), { a: 1, b: 2 }) - await assertFailure( - config, - ConfigProvider.fromUnknown({ a: "1", b: "value" }), - `Expected a string representing a finite number, got "value" - at ["b"]` - ) - }) + await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) + await assertFailure( + config, + ConfigProvider.fromUnknown("a"), + `Expected a string representing a finite number, got "a"` + ) + }) - describe("Tuple", () => { - it("ensure array", async () => { - const schema = Schema.Tuple([Schema.Number]) + it("decodes Boolean and rejects invalid input", async () => { + const schema = Schema.Boolean const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1]) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), [1]) + await assertSuccess(config, ConfigProvider.fromUnknown("true"), true) + await assertSuccess(config, ConfigProvider.fromUnknown("false"), false) + await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "true" | "false", got "a"`) + }) + + describe("Struct", () => { + it("requires and validates required properties", async () => { + const schema = Schema.Struct({ a: Schema.Finite }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) + await assertFailure( + config, + ConfigProvider.fromUnknown({}), + `Missing key + at ["a"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "value" }), + `Expected a string representing a finite number, got "value" + at ["a"]` + ) + }) + + it("omits absent optionalKey properties", async () => { + const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromUnknown({}), {}) + }) + + it("omits absent optional properties", async () => { + const config = Config.schema( + Schema.Struct({ a: Schema.optional(Schema.Number) }) + ) + + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromUnknown({}), {}) + }) + + it("decodes literal properties", async () => { + const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) }) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "b" }), { a: "b" }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "c" }), { a: "c" }) + }) + + it("rejects scalar values for array properties", async () => { + const schema = Schema.Struct({ a: Schema.Array(Schema.Number) }) + const config = Config.schema(schema) + + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "" }), + `Missing key + at ["a"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "" }, { preserveEmptyStrings: true }), + `Expected array, got undefined + at ["a"]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "1" }), + `Expected array, got undefined + at ["a"]` + ) + }) }) - it("required elements", async () => { - const schema = Schema.Tuple([Schema.String, Schema.Finite]) + it("decodes and validates Record values", async () => { + const schema = Schema.Record(Schema.String, Schema.Finite) const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown(["a", "2"]), ["a", 2]) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", b: "2" }), { a: 1, b: 2 }) await assertFailure( config, - ConfigProvider.fromUnknown(["a"]), - `Missing key - at [1]` + ConfigProvider.fromUnknown({ a: "1", b: "value" }), + `Expected a string representing a finite number, got "value" + at ["b"]` ) + }) + + describe("Tuple", () => { + it("accepts array tuple input and rejects scalar input", async () => { + const schema = Schema.Tuple([Schema.Number]) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1]) + await assertFailure(config, ConfigProvider.fromUnknown("1"), `Expected array, got undefined`) + }) + + it("requires and validates every tuple element", async () => { + const schema = Schema.Tuple([Schema.String, Schema.Finite]) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown(["a", "2"]), ["a", 2]) + await assertFailure( + config, + ConfigProvider.fromUnknown(["a"]), + `Missing key + at [1]` + ) + await assertFailure( + config, + ConfigProvider.fromUnknown(["a", "value"]), + `Expected a string representing a finite number, got "value" + at [1]` + ) + }) + }) + + it("accepts array input and rejects scalar Array input", async () => { + const schema = Schema.Array(Schema.Finite) + const config = Config.schema(schema) + + await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1]) + await assertFailure(config, ConfigProvider.fromUnknown("1"), `Expected array, got undefined`) + await assertSuccess(config, ConfigProvider.fromUnknown(["1", "2"]), [1, 2]) await assertFailure( config, - ConfigProvider.fromUnknown(["a", "value"]), + ConfigProvider.fromUnknown(["1", "value"]), `Expected a string representing a finite number, got "value" at [1]` ) }) - }) - - it("Array(Finite)", async () => { - const schema = Schema.Array(Schema.Finite) - const config = Config.schema(schema) - - await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1]) - // ensure array - await assertSuccess(config, ConfigProvider.fromUnknown("1"), [1]) - await assertSuccess(config, ConfigProvider.fromUnknown(["1", "2"]), [1, 2]) - await assertFailure( - config, - ConfigProvider.fromUnknown(["1", "value"]), - `Expected a string representing a finite number, got "value" - at [1]` - ) - }) - describe("Union", () => { - describe("Literals", () => { - it("string", async () => { + describe("Union", () => { + it("decodes literal unions", async () => { const schema = Schema.Literals(["a", "b"]) const config = Config.schema(schema) await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a") await assertSuccess(config, ConfigProvider.fromUnknown("b"), "b") }) - }) - it("inclusive", async () => { - const schema = Schema.Union([ - Schema.Struct({ a: Schema.String }), - Schema.Struct({ b: Schema.Number }) - ]) - const config = Config.schema(schema) + it("uses first-match semantics by default", async () => { + const schema = Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ]) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" }) - await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 }) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), { a: "a" }) - }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" }) + await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), { a: "a" }) + }) - it("exclusive", async () => { - const schema = Schema.Union([ - Schema.Struct({ a: Schema.String }), - Schema.Struct({ b: Schema.Number }) - ], { mode: "oneOf" }) - const config = Config.schema(schema) + it("enforces exactly one match in oneOf mode", async () => { + const schema = Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ], { mode: "oneOf" }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" }) - await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 }) - await assertFailure( - config, - ConfigProvider.fromUnknown({ a: "a", b: "1" }), - `Expected exactly one member to match the input {"a":"a","b":"1"}` - ) - }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" }) + await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 }) + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "a", b: "1" }), + `Expected exactly one member to match the input ` + ) + }) - it("number | string", async () => { - const schema = Schema.Union([Schema.Number, Schema.String]) - const config = Config.schema(schema) + it("decodes Number before String", async () => { + const schema = Schema.Union([Schema.Number, Schema.String]) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) - await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a") - }) + await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) + await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a") + }) - it("string | number", async () => { - const schema = Schema.Union([Schema.String, Schema.Number]) - const config = Config.schema(schema) + it("still decodes numeric input when String is listed first", async () => { + const schema = Schema.Union([Schema.String, Schema.Number]) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) - await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a") + await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1) + await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a") + }) }) - }) - it("Suspend", async () => { - interface A { - readonly a: string - readonly as: ReadonlyArray - } - const schema = Schema.Struct({ - a: Schema.String, - as: Schema.Array(Schema.suspend((): Schema.Codec => schema)) - }) - const config = Config.schema(schema) + it("decodes recursive suspended schemas", async () => { + interface A { + readonly a: string + readonly as: ReadonlyArray + } + const schema = Schema.Struct({ + a: Schema.String, + as: Schema.Array(Schema.suspend((): Schema.Codec => schema)) + }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [] }), { a: "1", as: [] }) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [{ a: "2", as: [] }] }), { - a: "1", - as: [{ a: "2", as: [] }] + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [] }), { a: "1", as: [] }) + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [{ a: "2", as: [] }] }), { + a: "1", + as: [{ a: "2", as: [] }] + }) }) - }) - it("Redacted(Int)", async () => { - const schema = Schema.Struct({ a: Schema.Redacted(Schema.Int) }) - const config = Config.schema(schema) + it("redacts nested Int validation errors", async () => { + const schema = Schema.Struct({ a: Schema.Redacted(Schema.Int) }) + const config = Config.schema(schema) - await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: Redacted.make(1) }) - await assertFailure( - config, - ConfigProvider.fromUnknown({}), - `Missing key + await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: Redacted.make(1) }) + await assertFailure( + config, + ConfigProvider.fromUnknown({}), + `Missing key at ["a"]` - ) - await assertFailure( - config, - ConfigProvider.fromUnknown({ a: "1.1" }), - `Invalid data + ) + await assertFailure( + config, + ConfigProvider.fromUnknown({ a: "1.1" }), + `Invalid data at ["a"]` - ) - }) + ) + }) - it("URL", async () => { - const schema = Schema.Struct({ a: Schema.URL }) - const config = Config.schema(schema) + it("decodes nested URL values", async () => { + const schema = Schema.Struct({ a: Schema.URL }) + const config = Config.schema(schema) - await assertSuccess( - config, - ConfigProvider.fromUnknown({ a: "https://example.com" }), - { a: new URL("https://example.com") } - ) + await assertSuccess( + config, + ConfigProvider.fromUnknown({ a: "https://example.com" }), + { a: new URL("https://example.com") } + ) + }) }) }) }) diff --git a/packages/effect/test/ConfigProvider.test.ts b/packages/effect/test/ConfigProvider.test.ts index 7be5b199ff3..2db1fc0bbc6 100644 --- a/packages/effect/test/ConfigProvider.test.ts +++ b/packages/effect/test/ConfigProvider.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "@effect/vitest" import { deepStrictEqual } from "@effect/vitest/utils" -import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, PlatformError, Result } from "effect" +import { ConfigProvider, Effect, FileSystem, Layer, Path, PlatformError, Result } from "effect" const notFound = (method: string): PlatformError.PlatformError => PlatformError.systemError({ @@ -15,7 +15,7 @@ async function assertSuccess( expected: ConfigProvider.Node ) { const r = Effect.result(provider.load(path)) - deepStrictEqual(await Effect.runPromise(r), Result.succeed(Option.some(expected))) + deepStrictEqual(await Effect.runPromise(r), Result.succeed(expected)) } async function assertMissing( @@ -23,7 +23,7 @@ async function assertMissing( path: ConfigProvider.Path ) { const r = Effect.result(provider.load(path)) - deepStrictEqual(await Effect.runPromise(r), Result.succeed(Option.none())) + deepStrictEqual(await Effect.runPromise(r), Result.succeed(undefined)) } async function assertFailure( @@ -37,7 +37,7 @@ async function assertFailure( describe("ConfigProvider", () => { describe("make", () => { - it.effect("exposes optional lookup and input transformation as provider behavior", () => + it.effect("exposes lookup absence and input transformation as provider behavior", () => Effect.gen(function*() { const provider = ConfigProvider.fromUnknown({ APP: { @@ -48,17 +48,17 @@ describe("ConfigProvider", () => { deepStrictEqual( yield* nested.load(["PORT"]), - Option.some(ConfigProvider.makeValue("3000")) + ConfigProvider.makeValue("3000") ) - deepStrictEqual(yield* nested.load(["MISSING"]), Option.none()) + deepStrictEqual(yield* nested.load(["MISSING"]), undefined) })) it("creates a provider from a lookup function", async () => { const provider = ConfigProvider.make((path) => Effect.succeed( path.join(".") === "A.B" - ? Option.some(ConfigProvider.makeValue("value")) - : Option.none() + ? ConfigProvider.makeValue("value") + : undefined ) ) @@ -1043,7 +1043,7 @@ DB_PASS=$PASSWORD`) ) ) - deepStrictEqual(result, Option.some(ConfigProvider.makeValue("value"))) + deepStrictEqual(result, ConfigProvider.makeValue("value")) }) it("layer accepts an Effect that produces a provider", async () => { @@ -1053,7 +1053,7 @@ DB_PASS=$PASSWORD`) ) ) - deepStrictEqual(result, Option.some(ConfigProvider.makeValue("value"))) + deepStrictEqual(result, ConfigProvider.makeValue("value")) }) it("layerAdd adds an Effect-produced provider as fallback", async () => { @@ -1078,9 +1078,9 @@ DB_PASS=$PASSWORD`) ) deepStrictEqual(result, { - current: Option.some(ConfigProvider.makeValue("current")), - fallback: Option.some(ConfigProvider.makeValue("fallback")), - shared: Option.some(ConfigProvider.makeValue("current")) + current: ConfigProvider.makeValue("current"), + fallback: ConfigProvider.makeValue("fallback"), + shared: ConfigProvider.makeValue("current") }) }) @@ -1106,9 +1106,9 @@ DB_PASS=$PASSWORD`) ) deepStrictEqual(result, { - current: Option.some(ConfigProvider.makeValue("current")), - primary: Option.some(ConfigProvider.makeValue("primary")), - shared: Option.some(ConfigProvider.makeValue("primary")) + current: ConfigProvider.makeValue("current"), + primary: ConfigProvider.makeValue("primary"), + shared: ConfigProvider.makeValue("primary") }) }) }) diff --git a/packages/effect/test/cluster/ShardingConfig.test.ts b/packages/effect/test/cluster/ShardingConfig.test.ts new file mode 100644 index 00000000000..3e6cd4d7f07 --- /dev/null +++ b/packages/effect/test/cluster/ShardingConfig.test.ts @@ -0,0 +1,37 @@ +import { assert, describe, it } from "@effect/vitest" +import { ConfigProvider, Effect, Option } from "effect" +import { RunnerAddress, ShardingConfig } from "effect/unstable/cluster" + +describe("ShardingConfig", () => { + it.effect("treats the optional listen address as an atomic group", () => + Effect.gen(function*() { + const defaults = yield* ShardingConfig.config.parse(ConfigProvider.fromUnknown({})) + assert.ok(Option.isNone(defaults.runnerListenAddress)) + + const withHost = yield* ShardingConfig.config.parse( + ConfigProvider.fromUnknown({ listenHost: "0.0.0.0" }) + ) + assert.deepStrictEqual( + Option.getOrThrow(withHost.runnerListenAddress), + RunnerAddress.make("0.0.0.0", 34431) + ) + + const missingHost = yield* ShardingConfig.config.parse( + ConfigProvider.fromUnknown({ listenPort: "8080" }) + ).pipe(Effect.flip) + assert.strictEqual( + missingHost.cause.message, + `Expected string, got undefined + at ["listenHost"]` + ) + + const invalidPort = yield* ShardingConfig.config.parse( + ConfigProvider.fromUnknown({ listenHost: "0.0.0.0", listenPort: "invalid" }) + ).pipe(Effect.flip) + assert.strictEqual( + invalidPort.cause.message, + `Expected a string representing a finite number, got "invalid" + at ["listenPort"]` + ) + })) +}) diff --git a/packages/effect/typetest/Config.tst.ts b/packages/effect/typetest/Config.tst.ts index aa760eafa3c..b3ccab9abb5 100644 --- a/packages/effect/typetest/Config.tst.ts +++ b/packages/effect/typetest/Config.tst.ts @@ -1,4 +1,4 @@ -import { Config, Schema } from "effect" +import { Config, ConfigProvider, Schema } from "effect" import { describe, expect, it } from "tstyche" describe("Config", () => { @@ -40,4 +40,13 @@ describe("Config", () => { expect(c).type.toBe>>() }) + + it("parse", () => { + const config = Config.string("a") + const provider = ConfigProvider.fromUnknown({ a: "value" }) + + config.parse(provider) + // @ts-expect-error Expected 1 arguments, but got 2. + config.parse(provider, ["prefix"]) + }) }) diff --git a/packages/effect/typetest/ConfigProvider.tst.ts b/packages/effect/typetest/ConfigProvider.tst.ts index 68caf741a56..ff881e1d0b0 100644 --- a/packages/effect/typetest/ConfigProvider.tst.ts +++ b/packages/effect/typetest/ConfigProvider.tst.ts @@ -1,12 +1,12 @@ -import { ConfigProvider, Effect, Option } from "effect" +import { ConfigProvider, Effect } from "effect" import { describe, expect, it } from "tstyche" describe("ConfigProvider", () => { - it("exposes optional lookup and input transformation", () => { - const provider = ConfigProvider.make((_path) => Effect.succeed(Option.some(ConfigProvider.makeValue("value")))) + it("exposes lookup absence and input transformation", () => { + const provider = ConfigProvider.make((_path) => Effect.succeed(ConfigProvider.makeValue("value"))) expect(provider.load([])) - .type.toBe, ConfigProvider.SourceError>>() + .type.toBe>() expect(provider.mapInput((path) => path)) .type.toBe() })