1import { assert, describe, it } from "@effect/vitest"
2import {
3 ByteSize,
4 Config,
5 ConfigProvider,
6 Duration,
7 Effect,
8 Option,
9 pipe,
10 Redacted,
11 Result,
12 Schema,
13 SchemaIssue,
14 SchemaTransformation
15} from "effect"
16import { vi } from "vitest"
17import type * as ConfigProviderModule from "../src/ConfigProvider.ts"
18
19async function assertSuccess<T>(config: Config.Config<T>, provider: ConfigProvider.ConfigProvider, expected: T) {
20 const r = await config.parse(provider).pipe(
21 Effect.result,
22 Effect.runPromise
23 )
24 assert.deepStrictEqual(r, Result.succeed(expected))
25}
26
27async function assertFailure<T>(config: Config.Config<T>, provider: ConfigProvider.ConfigProvider, message: string) {
28 const r = await config.parse(provider).pipe(
29 Effect.mapError((e) => e.cause.message),
30 Effect.result,
31 Effect.runPromise
32 )
33 assert.deepStrictEqual(r, Result.fail(message))
34}
35
36describe("Config", () => {
37 it("recognizes SourceError defects from a reloaded module copy", async () => {
38 vi.resetModules()
39 const ForeignConfigProvider = await vi.importActual<typeof ConfigProviderModule>(
40 "../src/ConfigProvider.ts"
41 )
42 const sourceError = new ForeignConfigProvider.SourceError({ message: "source unavailable" })
43 assert.isFalse(sourceError instanceof ConfigProvider.SourceError)
44
45 const provider = ConfigProvider.make(() => Effect.die(sourceError))
46 const error = await Config.String("value").parse(provider).pipe(
47 Effect.flip,
48 Effect.runPromise
49 )
50
51 assert.strictEqual(error.cause, sourceError)
52 })
53
54 it.effect("uses the current ConfigProvider when yielded as an Effect", () =>
55 Effect.gen(function*() {
56 const provider = ConfigProvider.fromEnv({ env: { STRING: "value" } })
57 const result = yield* Effect.provide(
58 Config.schema(Schema.Struct({ STRING: Schema.String })),
59 ConfigProvider.layer(provider)
60 )
61
62 assert.deepStrictEqual(result, { STRING: "value" })
63 }))
64
65 it.effect("preserves provider defects at the root and in nested fields", () =>
66 Effect.gen(function*() {
67 const defect = new Error("provider defect")
68 for (const failedPath of ["settings", "settings.value"]) {
69 const provider = ConfigProvider.make((path) =>
70 Effect.suspend(() =>
71 path.join(".") === failedPath
72 ? Effect.die(defect)
73 : Effect.succeed(ConfigProvider.makeRecord(new Set(["value"])))
74 )
75 )
76 let recovered = false
77 const config = Config.schema(Schema.Struct({ value: Schema.String }), "settings").pipe(
78 Config.orElse(() => {
79 recovered = true
80 return Config.succeed({ value: "fallback" })
81 }),
82 Config.option
83 )
84 const result = yield* config.parse(provider).pipe(Effect.catchDefect(Effect.succeed))
85
86 assert.strictEqual(result, defect)
87 assert.strictEqual(recovered, false)
88 }
89 }))
90
91 describe("constructors", () => {
92 it("fail creates an always-failing config", async () => {
93 await assertFailure(
94 Config.fail(
95 new Schema.SchemaError(new SchemaIssue.Forbidden({ message: "failure message" }))
96 ),
97 ConfigProvider.fromUnknown({}),
98 `failure message`
99 )
100 })
101
102 it("succeed creates a provider-independent value", async () => {
103 const provider = ConfigProvider.fromUnknown({})
104 await assertSuccess(Config.succeed(1), provider, 1)
105 })
106
107 it("string decodes present input and reports absence", async () => {
108 const provider = ConfigProvider.fromUnknown({ a: "value" })
109 await assertSuccess(Config.String("a"), provider, "value")
110 await assertFailure(
111 Config.String("b"),
112 provider,
113 `Expected string
114 at ["b"]`
115 )
116 })
117
118 it("nonEmptyString rejects preserved empty input", async () => {
119 const provider = ConfigProvider.fromUnknown({ a: "value", b: "" }, { preserveEmptyStrings: true })
120 await assertSuccess(Config.NonEmptyString("a"), provider, "value")
121 await assertFailure(
122 Config.NonEmptyString("b"),
123 provider,
124 `Expected a value with a length of at least 1
125 at ["b"]`
126 )
127 })
128
129 it("number accepts finite and non-finite numbers", async () => {
130 const provider = ConfigProvider.fromUnknown({ a: "1", c: "c", d: "Infinity" })
131 await assertSuccess(Config.Number("a"), provider, 1)
132 await assertSuccess(Config.Number("d"), provider, Infinity)
133 await assertFailure(
134 Config.Number("b"),
135 provider,
136 `Expected string | "Infinity" | "-Infinity" | "NaN"
137 at ["b"]`
138 )
139 })
140
141 it("finite rejects invalid and non-finite numbers", async () => {
142 const provider = ConfigProvider.fromUnknown({ a: "1", b: "a", c: "Infinity" })
143 await assertSuccess(Config.Finite("a"), provider, 1)
144 await assertFailure(
145 Config.Finite("b"),
146 provider,
147 `Expected a string representing a finite number
148 at ["b"]`
149 )
150 await assertFailure(
151 Config.Finite("c"),
152 provider,
153 `Expected a string representing a finite number
154 at ["c"]`
155 )
156 })
157
158 it("int rejects non-integer numbers", async () => {
159 const provider = ConfigProvider.fromUnknown({ a: "1", b: "1.2" })
160 await assertSuccess(Config.Int("a"), provider, 1)
161 await assertFailure(
162 Config.Int("b"),
163 provider,
164 `Expected an integer
165 at ["b"]`
166 )
167 })
168
169 it("literal accepts only the configured value", async () => {
170 const provider = ConfigProvider.fromUnknown({ a: "L" })
171 await assertSuccess(Config.Literal("L", "a"), provider, "L")
172 await assertFailure(
173 Config.Literal("-", "a"),
174 provider,
175 `Expected "-"
176 at ["a"]`
177 )
178 })
179
180 it("literals accepts configured string alternatives", async () => {
181 const provider = ConfigProvider.fromUnknown({ a: "production", b: "staging" })
182 await assertSuccess(Config.Literals(["development", "production"], "a"), provider, "production")
183 await assertFailure(
184 Config.Literals(["development", "production"], "b"),
185 provider,
186 `Expected "development" | "production"
187 at ["b"]`
188 )
189 })
190
191 it("literals accepts configured number alternatives", async () => {
192 const provider = ConfigProvider.fromUnknown({ a: "1", b: "3" })
193 await assertSuccess(Config.Literals([1, 2], "a"), provider, 1)
194 await assertFailure(
195 Config.Literals([1, 2], "b"),
196 provider,
197 `Expected "1" | "2"
198 at ["b"]`
199 )
200 })
201
202 it("date rejects invalid dates", async () => {
203 const provider = ConfigProvider.fromUnknown({ a: "2021-01-01", b: "invalid" })
204 await assertSuccess(Config.Date("a"), provider, new Date("2021-01-01"))
205 await assertFailure(
206 Config.Date("b"),
207 provider,
208 `Expected a valid Date
209 at ["b"]`
210 )
211 })
212
213 it("redacted creates redacted values and reports missing input", async () => {
214 const provider = ConfigProvider.fromUnknown({
215 a: "value"
216 })
217
218 await assertSuccess(Config.Redacted("a"), provider, Redacted.make("value"))
219 await assertFailure(
220 Config.Redacted("failure"),
221 provider,
222 `Expected string
223 at ["failure"]`
224 )
225 })
226
227 it("url decodes valid URLs and reports absence", async () => {
228 const provider = ConfigProvider.fromUnknown({
229 a: "https://example.com"
230 })
231
232 await assertSuccess(Config.URL("a"), provider, new URL("https://example.com"))
233 await assertFailure(
234 Config.URL("failure"),
235 provider,
236 `Expected string
237 at ["failure"]`
238 )
239 })
240 })
241
242 describe("combinators", () => {
243 it("map transforms successful values in data-first and data-last form", async () => {
244 const config = Config.schema(Schema.String)
245
246 await assertSuccess(
247 Config.map(config, (value) => value.toUpperCase()),
248 ConfigProvider.fromUnknown("value"),
249 "VALUE"
250 )
251 await assertSuccess(
252 pipe(config, Config.map((value) => value.toUpperCase())),
253 ConfigProvider.fromUnknown("value"),
254 "VALUE"
255 )
256 })
257
258 it("mapOrFail supports effectful validation", async () => {
259 const config = Config.schema(Schema.String)
260 const f = (s: string) =>
261 s === ""
262 ? Effect.fail(
263 new Config.ConfigError(
264 new Schema.SchemaError(new SchemaIssue.InvalidValue({ message: "empty" }))
265 )
266 )
267 : Effect.succeed(s.toUpperCase())
268
269 await assertSuccess(
270 Config.mapEffect(config, f),
271 ConfigProvider.fromUnknown("value"),
272 "VALUE"
273 )
274 await assertFailure(
275 Config.mapEffect(config, f),
276 ConfigProvider.fromUnknown("", { preserveEmptyStrings: true }),
277 `empty`
278 )
279 })
280
281 it("orElse evaluates the fallback after absence", async () => {
282 const config = Config.orElse(Config.String("a"), () => Config.Finite("b"))
283
284 await assertSuccess(
285 config,
286 ConfigProvider.fromUnknown({ a: "value" }),
287 "value"
288 )
289 await assertSuccess(
290 config,
291 ConfigProvider.fromUnknown({ b: "1" }),
292 1
293 )
294 })
295
296 describe("flatMap", () => {
297 const hostConfig = Config.flatMap(Config.Int("port"), (port) =>
298 Schema.String.pipe(
299 Schema.check(Schema.makeFilter((s) =>
300 s.endsWith("effect.website")
301 ? undefined
302 : new SchemaIssue.InvalidValue({ message: `Must end with "effect.website"` })
303 )),
304 (schema) => Config.schema(schema, port === 80 ? "prodHost" : "devHost")
305 ))
306
307 it("lets an inner config propagate parsing failure", () =>
308 assertFailure(
309 hostConfig,
310 ConfigProvider.fromUnknown({ port: 80, prodHost: "example.com" }),
311 `Must end with "effect.website"\n at ["prodHost"]`
312 ))
313
314 it("lets an inner config propagate key absence", () =>
315 assertFailure(
316 hostConfig,
317 ConfigProvider.fromUnknown({ port: 80 }),
318 `Expected string\n at ["prodHost"]`
319 ))
320
321 it("lets recover after an inner config propagated parsing failure", () =>
322 assertSuccess(
323 Config.orElse(hostConfig, (err) => Config.succeed(err.message)),
324 ConfigProvider.fromUnknown({ port: 80, prodHost: "example.com" }),
325 `SchemaError(Must end with "effect.website"\n at ["prodHost"])`
326 ))
327
328 it("lets recover after an inner config propagated key absence", () =>
329 assertSuccess(
330 Config.withDefault(hostConfig, "localhost"),
331 ConfigProvider.fromUnknown({ port: 80 }),
332 "localhost"
333 ))
334
335 it("propagates inner config success", () =>
336 assertSuccess(
337 hostConfig,
338 ConfigProvider.fromUnknown({ port: 3000, devHost: "stage.effect.website" }),
339 "stage.effect.website"
340 ))
341
342 it("lets the base config propagate parsing failure", () =>
343 assertFailure(
344 hostConfig,
345 ConfigProvider.fromUnknown({ port: "zzz", prodHost: "effect.website", devHost: "effect.website" }),
346 `Expected a string representing a finite number\n at ["port"]`
347 ))
348
349 it("lets the base config propagate key absence", () =>
350 assertFailure(
351 hostConfig,
352 ConfigProvider.fromUnknown({ prodHost: "effect.website", devHost: "effect.website" }),
353 `Expected string\n at ["port"]`
354 ))
355
356 it("lets recover after the base config propagated parsing failure", () =>
357 assertSuccess(
358 Config.orElse(hostConfig, (err) => Config.succeed(err.message)),
359 ConfigProvider.fromUnknown({ port: "zzz", prodHost: "effect.website", devHost: "effect.website" }),
360 `SchemaError(Expected a string representing a finite number\n at ["port"])`
361 ))
362
363 it("lets recover after the base config propagated key absence", () =>
364 assertSuccess(
365 Config.withDefault(hostConfig, "localhost"),
366 ConfigProvider.fromUnknown({ prodHost: "effect.website", devHost: "effect.website" }),
367 "localhost"
368 ))
369
370 it("handles chains of multiple flatMaps and withDefaults", async () => {
371 const symbol = Symbol()
372 const withAbsenceFallback =
373 <A, B>(fallback: Config.Config<A>) => (self: Config.Config<B>): Config.Config<A | B> =>
374 Config.flatMap(
375 Config.withDefault(self, symbol),
376 (e) => e === symbol ? fallback : Config.succeed<A | B>(e)
377 )
378
379 const portConfig = Config.Port("BACKEND_PORT").pipe(
380 withAbsenceFallback(Config.Port("POOORT")),
381 withAbsenceFallback(Config.Port("PORT")),
382 Config.withDefault(3001)
383 )
384
385 await assertSuccess(portConfig, ConfigProvider.fromUnknown({}), 3001)
386 await assertSuccess(
387 portConfig,
388 ConfigProvider.fromUnknown({ BACKEND_PORT: 5000, PORT: 99999 }),
389 5000
390 )
391 await assertSuccess(
392 portConfig,
393 ConfigProvider.fromUnknown({ PORT: 5000 }),
394 5000
395 )
396 await assertFailure(
397 portConfig,
398 ConfigProvider.fromUnknown({ PORT: 99999 }),
399 `Expected a value between 1 and 65535\n at ["PORT"]`
400 )
401 await assertFailure(
402 portConfig,
403 ConfigProvider.fromUnknown({ BACKEND_PORT: 99999, PORT: 80 }),
404 `Expected a value between 1 and 65535\n at ["BACKEND_PORT"]`
405 )
406 })
407
408 it("lifts absence in nested composition", async () => {
409 const config = Config.all({
410 flag: Config.Int("port").pipe(
411 Config.flatMap(() => Config.option(Config.String("unused")))
412 ),
413 required: Config.String("required")
414 }).pipe(Config.withDefault({ flag: Option.none(), required: "default" }))
415
416 await assertSuccess(
417 config,
418 ConfigProvider.fromUnknown({ port: "80" }),
419 { flag: Option.none(), required: "default" }
420 )
421 })
422
423 it.effect("matches map when chained with succeed, including grouped defaults and options", () =>
424 Effect.gen(function*() {
425 const mapped = Config.Int("port").pipe(Config.map((port) => port + 1))
426 const chained = Config.Int("port").pipe(Config.flatMap((port) => Config.succeed(port + 1)))
427 const group = (port: Config.Config<number>) => Config.all({ port, host: Config.String("host") })
428 const wrappers: Array<(config: Config.Config<number>) => Config.Config<unknown>> = [
429 (config) => config,
430 Config.option,
431 Config.withDefault(3000),
432 group,
433 (config) => group(config).pipe(Config.option),
434 (config) => group(config).pipe(Config.withDefault({ port: 3000, host: "default" }))
435 ]
436
437 for (
438 const input of [
439 {},
440 { port: "80" },
441 { host: "localhost" },
442 { port: "80", host: "localhost" },
443 { port: "invalid" },
444 { port: "invalid", host: "localhost" }
445 ]
446 ) {
447 const provider = ConfigProvider.fromUnknown(input)
448 const parse = (config: Config.Config<unknown>) =>
449 config.parse(provider).pipe(Effect.mapError((error) => error.cause.message), Effect.result)
450
451 for (const wrap of wrappers) {
452 assert.deepStrictEqual(yield* parse(wrap(chained)), yield* parse(wrap(mapped)))
453 }
454 }
455 }))
456
457 it.effect("preserves the outer prefix and composes prefixes in the selected config", () =>
458 Effect.gen(function*() {
459 const config = Config.Int("port").pipe(
460 Config.flatMap((port) => Config.String(port === 80 ? "prodHost" : "devHost").pipe(Config.nested("hosts"))),
461 Config.nested("service")
462 )
463 const root = {
464 port: 3000,
465 hosts: { prodHost: "root-prod", devHost: "root-dev" }
466 }
467 const provider = ConfigProvider.fromUnknown({
468 ...root,
469 service: {
470 port: 80,
471 prodHost: "wrong-local-path",
472 hosts: { prodHost: "service-prod", devHost: "service-dev" }
473 }
474 })
475 assert.strictEqual(yield* config.parse(provider), "service-prod")
476
477 const missing = ConfigProvider.fromUnknown({ ...root, service: { port: 80 } })
478 const error = yield* config.parse(missing).pipe(Effect.flip)
479 assert.strictEqual(error.cause.message, `Expected string\n at ["service"]["hosts"]["prodHost"]`)
480 assert.deepStrictEqual(yield* config.pipe(Config.option).parse(missing), Option.none())
481 }))
482 })
483
484 it.effect("defers user callbacks until the Config Effect is executed", () =>
485 Effect.gen(function*() {
486 const provider = ConfigProvider.fromUnknown({})
487 let mapCalls = 0
488 let mapOrFailCalls = 0
489 let orElseCalls = 0
490 const mapped = Config.succeed(1).pipe(
491 Config.map((value) => {
492 mapCalls++
493 return value + 1
494 })
495 ).parse(provider)
496 const mappedOrFailed = Config.succeed(1).pipe(
497 Config.mapEffect((value) => {
498 mapOrFailCalls++
499 return Effect.succeed(value + 1)
500 })
501 ).parse(provider)
502 const recovered = Config.fail(
503 new Schema.SchemaError(new SchemaIssue.Forbidden({ message: "failure" }))
504 ).pipe(
505 Config.orElse(() => {
506 orElseCalls++
507 return Config.succeed(1)
508 })
509 ).parse(provider)
510
511 assert.strictEqual(mapCalls, 0)
512 assert.strictEqual(mapOrFailCalls, 0)
513 assert.strictEqual(orElseCalls, 0)
514
515 yield* mapped
516 yield* mappedOrFailed
517 yield* recovered
518
519 assert.strictEqual(mapCalls, 1)
520 assert.strictEqual(mapOrFailCalls, 1)
521 assert.strictEqual(orElseCalls, 1)
522 }))
523
524 describe("all", () => {
525 it.effect("resolves empty groups without using a default", () =>
526 Effect.gen(function*() {
527 const provider = ConfigProvider.fromUnknown({})
528 assert.deepStrictEqual(yield* Config.all([]).pipe(Config.withDefault("fallback")).parse(provider), [])
529 assert.deepStrictEqual(yield* Config.all({}).pipe(Config.withDefault("fallback")).parse(provider), {})
530 assert.deepStrictEqual(
531 yield* Config.all(new Set<Config.Config<string>>()).pipe(Config.withDefault("fallback")).parse(provider),
532 []
533 )
534 }))
535
536 it.effect("preserves Result values as configuration data", () =>
537 Effect.gen(function*() {
538 const value = Result.fail("data")
539 const config = Config.all([
540 Config.succeed(value),
541 Config.succeed(value).pipe(Config.map((value) => value)),
542 Config.succeed(value).pipe(Config.mapEffect(Effect.succeed))
543 ]).pipe(Config.option)
544
545 assert.deepStrictEqual(
546 yield* config.parse(ConfigProvider.fromUnknown({})),
547 Option.some([value, value, value])
548 )
549 }))
550
551 it.effect("preserves special record keys without changing the prototype", () =>
552 Effect.gen(function*() {
553 const result = yield* Config.all({
554 ["__proto__"]: Config.succeed("value"),
555 constructor: Config.succeed(undefined)
556 }).parse(ConfigProvider.fromUnknown({}))
557
558 assert.deepStrictEqual(result, { ["__proto__"]: "value", constructor: undefined })
559 assert.strictEqual(Object.getPrototypeOf(result), Object.prototype)
560 }))
561
562 it("combines tuple inputs and preserves positions", async () => {
563 const config = Config.all([Config.NonEmptyString("a"), Config.Finite("b")])
564
565 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), ["a", 1])
566 await assertFailure(
567 config,
568 ConfigProvider.fromUnknown({ a: "", b: "1" }, { preserveEmptyStrings: true }),
569 `Expected a value with a length of at least 1
570 at ["a"]`
571 )
572 await assertFailure(
573 config,
574 ConfigProvider.fromUnknown({ a: "a", b: "b" }),
575 `Expected a string representing a finite number
576 at ["b"]`
577 )
578 })
579
580 it("combines generic iterables in iteration order", async () => {
581 const config = Config.all(new Set([Config.NonEmptyString("a"), Config.Finite("b")]))
582
583 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), ["a", 1])
584 await assertFailure(
585 config,
586 ConfigProvider.fromUnknown({ a: "", b: "1" }, { preserveEmptyStrings: true }),
587 `Expected a value with a length of at least 1
588 at ["a"]`
589 )
590 await assertFailure(
591 config,
592 ConfigProvider.fromUnknown({ a: "a", b: "b" }),
593 `Expected a string representing a finite number
594 at ["b"]`
595 )
596 })
597
598 it("combines named fields and preserves their keys", async () => {
599 const config = Config.all({ a: Config.NonEmptyString("b"), c: Config.Finite("d") })
600
601 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), { a: "b", c: 1 })
602 await assertFailure(
603 config,
604 ConfigProvider.fromUnknown({ b: "", d: "1" }, { preserveEmptyStrings: true }),
605 `Expected a value with a length of at least 1
606 at ["b"]`
607 )
608 await assertFailure(
609 config,
610 ConfigProvider.fromUnknown({ b: "b", d: "b" }),
611 `Expected a string representing a finite number
612 at ["d"]`
613 )
614 })
615 })
616
617 describe("withDefault", () => {
618 it("uses the parsed value when present and the default when absent", async () => {
619 const defaultValue = 0
620 const config = Config.Finite("a").pipe(Config.withDefault(defaultValue))
621
622 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), 1)
623 await assertSuccess(config, ConfigProvider.fromUnknown({}), defaultValue)
624 await assertFailure(
625 config,
626 ConfigProvider.fromUnknown({ a: "value" }),
627 `Expected a string representing a finite number
628 at ["a"]`
629 )
630 })
631
632 it("supports redacted default values", async () => {
633 const defaultValue = Redacted.make("default")
634 const config = Config.Redacted("a").pipe(Config.withDefault(defaultValue))
635
636 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "value" }), Redacted.make("value"))
637 await assertSuccess(config, ConfigProvider.fromUnknown({}), defaultValue)
638 })
639
640 it("treats ignored empty env strings as absent", async () => {
641 const config = Config.String("a").pipe(Config.withDefault("default"))
642
643 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), "default")
644 await assertSuccess(
645 config,
646 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
647 ""
648 )
649 })
650
651 it("validates empty env numbers when they are preserved", async () => {
652 const config = Config.Number("a").pipe(Config.withDefault(0))
653
654 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), 0)
655 await assertFailure(
656 config,
657 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
658 `Expected a string representing a finite number
659 at ["a"]
660Expected "Infinity" | "-Infinity" | "NaN"
661 at ["a"]`
662 )
663 })
664
665 it("defaults the whole product when any required child is absent", async () => {
666 const defaultValue = { a: "a", c: 0 }
667 const config = Config.all({ a: Config.NonEmptyString("b"), c: Config.Finite("d") }).pipe(
668 Config.withDefault(defaultValue)
669 )
670
671 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), { a: "b", c: 1 })
672 await assertSuccess(config, ConfigProvider.fromUnknown({}), defaultValue)
673 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b" }), defaultValue)
674 await assertSuccess(config, ConfigProvider.fromUnknown({ d: "1" }), defaultValue)
675
676 await assertFailure(
677 config,
678 ConfigProvider.fromUnknown({ b: "", d: "1" }, { preserveEmptyStrings: true }),
679 `Expected a value with a length of at least 1
680 at ["b"]`
681 )
682 })
683
684 it("does not recover from invalid union input", async () => {
685 const config = Config.LogLevel("LOG_LEVEL").pipe(Config.withDefault("Info"))
686
687 await assertSuccess(config, ConfigProvider.fromUnknown({}), "Info")
688 await assertFailure(
689 config,
690 ConfigProvider.fromUnknown({ LOG_LEVEL: "debug" }),
691 `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None"
692 at ["LOG_LEVEL"]`
693 )
694 })
695
696 it("does not recover from schema refinement failures", async () => {
697 const schema = Schema.String.check(
698 Schema.makeFilter((s) => s === "a" ? undefined : new SchemaIssue.InvalidValue({ message: `must be "a"` }))
699 )
700 const config = Config.schema(schema, "a").pipe(Config.withDefault("fallback"))
701
702 // missing key -> default
703 await assertSuccess(config, ConfigProvider.fromUnknown({}), "fallback")
704 // valid present value -> parsed
705 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), "a")
706 // present value that fails the refinement must fail, not use the default
707 await assertFailure(
708 config,
709 ConfigProvider.fromUnknown({ a: "b" }),
710 `must be "a"
711 at ["a"]`
712 )
713 })
714
715 it("uses the default unless a plain Array schema receives an array representation", async () => {
716 const config = Config.schema(Schema.Array(Schema.String), "a").pipe(Config.withDefault(["default"]))
717
718 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "value" } }), ["default"])
719 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), ["default"])
720 await assertSuccess(
721 config,
722 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
723 ["default"]
724 )
725 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "value" } }), ["value"])
726 await assertSuccess(config, ConfigProvider.fromEnv({ env: {} }), ["default"])
727 })
728
729 it("defaults absent named containers and preserves explicit empty containers", async () => {
730 const absent = ConfigProvider.fromUnknown({})
731
732 await assertSuccess(
733 Config.schema(Schema.Struct({ value: Schema.String }), "a").pipe(Config.withDefault({ value: "default" })),
734 absent,
735 { value: "default" }
736 )
737 await assertSuccess(
738 Config.schema(Schema.Struct({ value: Schema.optionalKey(Schema.String) }), "a").pipe(
739 Config.withDefault({ value: "default" })
740 ),
741 absent,
742 { value: "default" }
743 )
744 await assertSuccess(
745 Config.schema(Schema.Struct({}), "a").pipe(Config.withDefault({ value: "default" })),
746 absent,
747 { value: "default" }
748 )
749 await assertSuccess(
750 Config.schema(Schema.Record(Schema.String, Schema.String), "a").pipe(
751 Config.withDefault({ value: "default" })
752 ),
753 absent,
754 { value: "default" }
755 )
756 await assertSuccess(
757 Config.schema(Schema.Tuple([]), "a").pipe(Config.withDefault(["default"])),
758 absent,
759 ["default"]
760 )
761 await assertSuccess(
762 Config.schema(Schema.Tuple([Schema.String]), "a").pipe(Config.withDefault(["default"])),
763 absent,
764 ["default"]
765 )
766 await assertSuccess(
767 Config.schema(Schema.ReadonlySet(Schema.String), "a").pipe(Config.withDefault(new Set(["default"]))),
768 absent,
769 new Set(["default"])
770 )
771 await assertSuccess(
772 Config.schema(Schema.ReadonlyMap(Schema.String, Schema.String), "a").pipe(
773 Config.withDefault(new Map([["default", "value"]]))
774 ),
775 absent,
776 new Map([["default", "value"]])
777 )
778
779 await assertSuccess(
780 Config.schema(Schema.Struct({ value: Schema.optionalKey(Schema.String) }), "a"),
781 ConfigProvider.fromUnknown({ a: {} }),
782 {}
783 )
784 await assertSuccess(
785 Config.schema(Schema.Record(Schema.String, Schema.String), "a"),
786 ConfigProvider.fromUnknown({ a: {} }),
787 {}
788 )
789 await assertSuccess(
790 Config.schema(Schema.Tuple([]), "a"),
791 ConfigProvider.fromUnknown({ a: [] }),
792 []
793 )
794 })
795
796 it("preserves values successfully decoded from undefined", async () => {
797 const config = Config.schema(Schema.UndefinedOr(Schema.String), "a").pipe(
798 Config.withDefault("default")
799 )
800
801 await assertSuccess(config, ConfigProvider.fromUnknown({}), undefined)
802 })
803 })
804
805 describe("option", () => {
806 it("wraps present values and maps absence to None", async () => {
807 const config = Config.Finite("a").pipe(Config.option)
808 const stringConfig = Config.String("a").pipe(Config.option)
809
810 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), Option.some(1))
811 await assertSuccess(config, ConfigProvider.fromUnknown({}), Option.none())
812 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "" } }), Option.none())
813 await assertSuccess(
814 stringConfig,
815 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
816 Option.some("")
817 )
818 await assertFailure(
819 config,
820 ConfigProvider.fromUnknown({ a: "value" }),
821 `Expected a string representing a finite number
822 at ["a"]`
823 )
824 })
825
826 it("returns None when any required child is absent", async () => {
827 const config = Config.all({ a: Config.NonEmptyString("b"), c: Config.Finite("d") }).pipe(
828 Config.option
829 )
830
831 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b", d: "1" }), Option.some({ a: "b", c: 1 }))
832 await assertSuccess(config, ConfigProvider.fromUnknown({}), Option.none())
833 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "b" }), Option.none())
834 await assertSuccess(config, ConfigProvider.fromUnknown({ d: "1" }), Option.none())
835 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "", d: "1" }), Option.none())
836
837 await assertFailure(
838 config,
839 ConfigProvider.fromUnknown({ b: "", d: "1" }, { preserveEmptyStrings: true }),
840 `Expected a value with a length of at least 1
841 at ["b"]`
842 )
843 })
844
845 it.effect("wraps successfully decoded undefined in Some", () =>
846 Effect.gen(function*() {
847 const config = Config.schema(Schema.UndefinedOr(Schema.String), "a").pipe(Config.option)
848
849 assert.deepStrictEqual(
850 yield* config.parse(ConfigProvider.fromUnknown({})),
851 Option.some(undefined)
852 )
853 }))
854 })
855
856 describe("absence semantics", () => {
857 describe("schema and all", () => {
858 const fallback = { host: "fallback", port: 0 }
859 const schemaConfig = Config.schema(
860 Schema.Struct({
861 host: Schema.String,
862 port: Schema.Finite
863 })
864 ).pipe(Config.nested("database"))
865 const allConfig = Config.all({
866 host: Config.String("host"),
867 port: Config.Finite("port")
868 }).pipe(Config.nested("database"))
869
870 it("default wholly absent nested configurations", async () => {
871 const provider = ConfigProvider.fromUnknown({})
872
873 await assertSuccess(schemaConfig.pipe(Config.withDefault(fallback)), provider, fallback)
874 await assertSuccess(allConfig.pipe(Config.withDefault(fallback)), provider, fallback)
875 })
876
877 it("distinguish an explicit empty schema container from an absent all group", async () => {
878 const provider = ConfigProvider.fromUnknown({ database: {} })
879
880 await assertFailure(
881 schemaConfig.pipe(Config.withDefault(fallback)),
882 provider,
883 `Missing key
884 at ["database"]["host"]`
885 )
886 await assertSuccess(allConfig.pipe(Config.withDefault(fallback)), provider, fallback)
887
888 await assertFailure(
889 schemaConfig.pipe(Config.option),
890 provider,
891 `Missing key
892 at ["database"]["host"]`
893 )
894 await assertSuccess(allConfig.pipe(Config.option), provider, Option.none())
895 })
896
897 it("validates partial schema objects and recovers absent all children", async () => {
898 const provider = ConfigProvider.fromUnknown({ database: { host: "localhost" } })
899
900 await assertFailure(
901 schemaConfig.pipe(Config.withDefault(fallback)),
902 provider,
903 `Missing key
904 at ["database"]["port"]`
905 )
906 await assertSuccess(allConfig.pipe(Config.withDefault(fallback)), provider, fallback)
907 await assertSuccess(allConfig.pipe(Config.option), provider, Option.none())
908 })
909
910 it.effect("recovers an absent child alongside a successfully decoded undefined", () =>
911 Effect.gen(function*() {
912 const config = Config.all({
913 optional: Config.schema(Schema.UndefinedOr(Schema.String), "optional"),
914 required: Config.String("required")
915 })
916 const fallback = { optional: "fallback", required: "fallback" }
917 const provider = ConfigProvider.fromUnknown({})
918
919 assert.deepStrictEqual(
920 yield* config.pipe(Config.withDefault(fallback)).parse(provider),
921 fallback
922 )
923 assert.deepStrictEqual(
924 yield* config.pipe(Config.option).parse(provider),
925 Option.none()
926 )
927 }))
928 })
929
930 it.effect("rejects invalid input alongside missing fields in either field order", () =>
931 Effect.gen(function*() {
932 const provider = ConfigProvider.fromUnknown({ invalid: "not-a-number" })
933 const schemaConfigs = [
934 Config.schema(
935 Schema.Struct({
936 missing: Schema.String,
937 invalid: Schema.Finite
938 })
939 ),
940 Config.schema(
941 Schema.Struct({
942 invalid: Schema.Finite,
943 missing: Schema.String
944 })
945 )
946 ]
947 const allConfigs = [
948 Config.all({
949 missing: Config.String("missing"),
950 invalid: Config.Finite("invalid")
951 }),
952 Config.all({
953 invalid: Config.Finite("invalid"),
954 missing: Config.String("missing")
955 })
956 ]
957
958 for (const config of [...schemaConfigs, ...allConfigs]) {
959 const error = yield* config.pipe(
960 Config.withDefault({ missing: "default", invalid: 0 }),
961 (config) => config.parse(provider),
962 Effect.flip
963 )
964 assert.ok(error instanceof Config.ConfigError)
965 }
966 }))
967
968 it.effect("replaces the whole group when a required child remains absent", () =>
969 Effect.gen(function*() {
970 const fallback = { required: "fallback", defaulted: 0 }
971 const config = Config.all({
972 required: Config.String("required"),
973 defaulted: Config.Int("defaulted").pipe(Config.withDefault(1))
974 }).pipe(Config.withDefault(fallback))
975
976 assert.deepStrictEqual(
977 yield* config.parse(ConfigProvider.fromUnknown({})),
978 fallback
979 )
980 assert.deepStrictEqual(
981 yield* config.parse(ConfigProvider.fromUnknown({ required: "value" })),
982 { required: "value", defaulted: 1 }
983 )
984 assert.deepStrictEqual(
985 yield* config.parse(ConfigProvider.fromUnknown({ defaulted: "2" })),
986 fallback
987 )
988 }))
989
990 it.effect("recovers missing tuple and iterable children without hiding invalid input", () =>
991 Effect.gen(function*() {
992 for (
993 const children of [
994 [Config.String("host"), Config.Int("port")],
995 [Config.Int("port"), Config.String("host")]
996 ]
997 ) {
998 for (const config of [Config.all(children), Config.all(new Set(children))]) {
999 for (const input of [{}, { port: "80" }, { host: "localhost" }]) {
1000 const provider = ConfigProvider.fromUnknown(input)
1001 assert.strictEqual(yield* config.pipe(Config.withDefault("default")).parse(provider), "default")
1002 assert.deepStrictEqual(yield* config.pipe(Config.option).parse(provider), Option.none())
1003 }
1004
1005 const missing = yield* config.parse(ConfigProvider.fromUnknown({ port: "80" })).pipe(Effect.flip)
1006 assert.strictEqual(missing.cause.message, `Expected string\n at ["host"]`)
1007
1008 const invalid = ConfigProvider.fromUnknown({ port: "invalid" })
1009 const wrappers: Array<Config.Config<unknown>> = [
1010 config.pipe(Config.withDefault("default")),
1011 config.pipe(Config.option)
1012 ]
1013 for (const wrapped of wrappers) {
1014 const error = yield* wrapped.parse(invalid).pipe(Effect.flip)
1015 assert.strictEqual(error.cause.message, `Expected a string representing a finite number\n at ["port"]`)
1016 }
1017 }
1018 }
1019 }))
1020
1021 it.effect("propagates source failures alongside absence in every group shape", () =>
1022 Effect.gen(function*() {
1023 const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" })
1024 const provider = ConfigProvider.make((path) =>
1025 path[0] === "failed" ? Effect.fail(sourceError) : Effect.succeed(undefined)
1026 )
1027 const configs: Array<Config.Config<unknown>> = [
1028 Config.all({ missing: Config.String("missing"), failed: Config.String("failed") }),
1029 Config.all({ failed: Config.String("failed"), missing: Config.String("missing") }),
1030 Config.all([Config.String("missing"), Config.String("failed")]),
1031 Config.all([Config.String("failed"), Config.String("missing")]),
1032 Config.all(new Set([Config.String("missing"), Config.String("failed")]))
1033 ]
1034 for (const config of configs) {
1035 for (const wrapped of [config.pipe(Config.withDefault("default")), config.pipe(Config.option)]) {
1036 const error = yield* wrapped.parse(provider).pipe(Effect.flip)
1037 assert.strictEqual(error.cause, sourceError)
1038 }
1039 }
1040 }))
1041
1042 it.effect("propagates nested group absence alongside a resolved sibling", () =>
1043 Effect.gen(function*() {
1044 const config = Config.all({
1045 name: Config.String("name"),
1046 database: Config.all({ host: Config.String("host"), port: Config.Int("port") }).pipe(
1047 Config.nested("database")
1048 )
1049 })
1050 const provider = ConfigProvider.fromUnknown({ name: "app", database: { host: "db.internal" } })
1051 const fallback = { name: "default", database: { host: "localhost", port: 5432 } }
1052
1053 assert.deepStrictEqual(yield* config.pipe(Config.withDefault(fallback)).parse(provider), fallback)
1054 assert.deepStrictEqual(yield* config.pipe(Config.option).parse(provider), Option.none())
1055
1056 const error = yield* config.parse(provider).pipe(Effect.flip)
1057 assert.strictEqual(error.cause.message, `Expected string\n at ["database"]["port"]`)
1058 }))
1059
1060 it.effect("treats mapped, effectfully mapped, and constant siblings alike", () =>
1061 Effect.gen(function*() {
1062 const provider = ConfigProvider.fromUnknown({ port: "80" })
1063 const fallback = { port: 3000, host: "localhost" }
1064 for (
1065 const port of [
1066 Config.Int("port").pipe(Config.map((value) => value + 1)),
1067 Config.Int("port").pipe(Config.mapEffect((value) => Effect.succeed(value + 1))),
1068 Config.succeed(81)
1069 ]
1070 ) {
1071 const config = Config.all({ port, host: Config.String("host") })
1072 assert.deepStrictEqual(yield* config.pipe(Config.withDefault(fallback)).parse(provider), fallback)
1073 assert.deepStrictEqual(yield* config.pipe(Config.option).parse(provider), Option.none())
1074 }
1075 }))
1076
1077 it.effect("recovers an absent sibling after orElse handles invalid input", () =>
1078 Effect.gen(function*() {
1079 const fallback = { recovered: 0, required: "default" }
1080 const config = Config.all({
1081 recovered: Config.Int("recovered").pipe(Config.orElse(() => Config.succeed(1))),
1082 required: Config.String("required")
1083 }).pipe(Config.withDefault(fallback))
1084
1085 assert.deepStrictEqual(
1086 yield* config.parse(ConfigProvider.fromUnknown({ recovered: "invalid" })),
1087 fallback
1088 )
1089 assert.deepStrictEqual(
1090 yield* config.parse(ConfigProvider.fromUnknown({ recovered: "invalid", required: "value" })),
1091 { recovered: 1, required: "value" }
1092 )
1093 }))
1094
1095 it.effect("recovers an absent sibling after orElse handles absence", () =>
1096 Effect.gen(function*() {
1097 const fallback = { recovered: 0, required: "default" }
1098 const config = Config.all({
1099 recovered: Config.Int("recovered").pipe(Config.orElse(() => Config.succeed(1))),
1100 required: Config.String("required")
1101 }).pipe(Config.withDefault(fallback))
1102
1103 assert.deepStrictEqual(
1104 yield* config.parse(ConfigProvider.fromUnknown({})),
1105 fallback
1106 )
1107 }))
1108
1109 it.effect("lets defaults and option handle an absent orElse fallback", () =>
1110 Effect.gen(function*() {
1111 const config = Config.Int("primary").pipe(
1112 Config.orElse(() => Config.String("fallback"))
1113 )
1114 const provider = ConfigProvider.fromUnknown({ primary: "invalid" })
1115
1116 assert.strictEqual(
1117 yield* config.pipe(Config.withDefault("default")).parse(provider),
1118 "default"
1119 )
1120 assert.deepStrictEqual(
1121 yield* config.pipe(Config.option).parse(provider),
1122 Option.none()
1123 )
1124 }))
1125
1126 it.effect("uses the orElse fallback result within the nested path", () =>
1127 Effect.gen(function*() {
1128 const config = Config.Int("primary").pipe(
1129 Config.orElse(() => Config.Int("fallback")),
1130 Config.nested("service")
1131 )
1132 assert.strictEqual(
1133 yield* config.parse(ConfigProvider.fromUnknown({ service: { primary: "invalid", fallback: "80" } })),
1134 80
1135 )
1136 assert.strictEqual(
1137 yield* config.pipe(Config.withDefault(3000)).parse(
1138 ConfigProvider.fromUnknown({ service: { primary: "invalid" }, fallback: "80" })
1139 ),
1140 3000
1141 )
1142
1143 const invalid = ConfigProvider.fromUnknown({ service: { primary: "invalid", fallback: "1.5" } })
1144 const wrappers: Array<Config.Config<unknown>> = [
1145 config.pipe(Config.withDefault(3000)),
1146 config.pipe(Config.option)
1147 ]
1148 for (const wrapped of wrappers) {
1149 const error = yield* wrapped.parse(invalid).pipe(Effect.flip)
1150 assert.strictEqual(error.cause.message, `Expected an integer\n at ["service"]["fallback"]`)
1151 }
1152 }))
1153
1154 it.effect("propagates an orElse fallback source error after recovering invalid input", () =>
1155 Effect.gen(function*() {
1156 const sourceError = new ConfigProvider.SourceError({ message: "fallback unavailable" })
1157 const provider = ConfigProvider.make((path) =>
1158 path[0] === "primary"
1159 ? Effect.succeed(ConfigProvider.makeValue("invalid"))
1160 : Effect.fail(sourceError)
1161 )
1162 const config = Config.Int("primary").pipe(Config.orElse(() => Config.Int("fallback")))
1163 const wrappers: Array<Config.Config<unknown>> = [
1164 config.pipe(Config.withDefault(3000)),
1165 config.pipe(Config.option)
1166 ]
1167 for (const wrapped of wrappers) {
1168 const error = yield* wrapped.parse(provider).pipe(Effect.flip)
1169 assert.strictEqual(error.cause, sourceError)
1170 }
1171 }))
1172
1173 it.effect("recovers an absent sibling after orElse handles mapEffect failure", () =>
1174 Effect.gen(function*() {
1175 const validationError = new Config.ConfigError(
1176 new Schema.SchemaError(new SchemaIssue.Forbidden({ message: "invalid value" }))
1177 )
1178 const fallback = { recovered: "default", required: "default" }
1179 const config = Config.all({
1180 recovered: Config.String("recovered").pipe(
1181 Config.mapEffect(() => Effect.fail(validationError)),
1182 Config.orElse(() => Config.succeed("fallback"))
1183 ),
1184 required: Config.String("required")
1185 }).pipe(Config.withDefault(fallback))
1186
1187 assert.deepStrictEqual(
1188 yield* config.parse(ConfigProvider.fromUnknown({ recovered: "value" })),
1189 fallback
1190 )
1191 }))
1192
1193 it.effect("recovers an absent sibling after orElse handles a descendant source failure", () =>
1194 Effect.gen(function*() {
1195 const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" })
1196 const provider = ConfigProvider.make((path) => {
1197 if (path.length === 0) {
1198 return Effect.succeed(ConfigProvider.makeRecord(new Set(["value"])))
1199 }
1200 return path.length === 1 && path[0] === "value"
1201 ? Effect.fail(sourceError)
1202 : Effect.succeed(undefined)
1203 })
1204 const fallback = { recovered: { value: "default" }, required: "default" }
1205 const config = Config.all({
1206 recovered: Config.schema(Schema.Struct({ value: Schema.String })).pipe(
1207 Config.orElse(() => Config.succeed({ value: "fallback" }))
1208 ),
1209 required: Config.String("required")
1210 }).pipe(Config.withDefault(fallback))
1211
1212 assert.deepStrictEqual(
1213 yield* config.parse(provider),
1214 fallback
1215 )
1216 }))
1217
1218 it.effect("recovers an absent sibling after orElse handles a nested group failure", () =>
1219 Effect.gen(function*() {
1220 const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" })
1221 const provider = ConfigProvider.make((path) => {
1222 if (path[0] === "failed") return Effect.fail(sourceError)
1223 if (path[0] === "present") return Effect.succeed(ConfigProvider.makeValue("value"))
1224 return Effect.succeed(undefined)
1225 })
1226 const recovered = Config.all({
1227 failed: Config.String("failed"),
1228 present: Config.String("present")
1229 }).pipe(Config.orElse(() => Config.succeed({ failed: "recovered", present: "recovered" })))
1230 const fallback = {
1231 recovered: { failed: "default", present: "default" },
1232 required: "default"
1233 }
1234 const config = Config.all({
1235 recovered,
1236 required: Config.String("required")
1237 }).pipe(Config.withDefault(fallback))
1238
1239 assert.deepStrictEqual(
1240 yield* config.parse(provider),
1241 fallback
1242 )
1243 }))
1244
1245 it.effect("recovers an absent sibling after orElse handles an initial source failure", () =>
1246 Effect.gen(function*() {
1247 const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" })
1248 const provider = ConfigProvider.make((path) =>
1249 path.length === 0 ? Effect.fail(sourceError) : Effect.succeed(undefined)
1250 )
1251 const fallback = { recovered: { value: "default" }, required: "default" }
1252 const config = Config.all({
1253 recovered: Config.schema(Schema.Struct({ value: Schema.String })).pipe(
1254 Config.orElse(() => Config.succeed({ value: "fallback" }))
1255 ),
1256 required: Config.String("required")
1257 }).pipe(Config.withDefault(fallback))
1258
1259 assert.deepStrictEqual(
1260 yield* config.parse(provider),
1261 fallback
1262 )
1263 }))
1264
1265 it.effect("normalizes unavailable scalar representations to absence", () =>
1266 Effect.gen(function*() {
1267 const provider = ConfigProvider.make((path) =>
1268 Effect.succeed(
1269 path.length === 1 && path[0] === "value"
1270 ? ConfigProvider.makeRecord(new Set())
1271 : undefined
1272 )
1273 )
1274 const config = Config.String("value")
1275
1276 assert.strictEqual(
1277 yield* config.pipe(Config.withDefault("default")).parse(provider),
1278 "default"
1279 )
1280 assert.deepStrictEqual(
1281 yield* config.pipe(Config.option).parse(provider),
1282 Option.none()
1283 )
1284 }))
1285
1286 it.effect("treats incompatible container representations as absent", () =>
1287 Effect.gen(function*() {
1288 const struct = Config.schema(
1289 Schema.Struct({ value: Schema.optionalKey(Schema.String) }),
1290 "value"
1291 )
1292 const array = Config.schema(Schema.Array(Schema.String), "value")
1293
1294 assert.deepStrictEqual(
1295 yield* struct.pipe(Config.withDefault({ value: "default" })).parse(
1296 ConfigProvider.fromUnknown({ value: [] })
1297 ),
1298 { value: "default" }
1299 )
1300 assert.deepStrictEqual(
1301 yield* array.pipe(Config.option).parse(
1302 ConfigProvider.fromUnknown({ value: {} })
1303 ),
1304 Option.none()
1305 )
1306 }))
1307
1308 it.effect("propagates provider failures", () =>
1309 Effect.gen(function*() {
1310 const cause = new ConfigProvider.SourceError({ message: "source unavailable" })
1311 const provider = ConfigProvider.make(() => Effect.fail(cause))
1312 const error = yield* Config.String("a").pipe(
1313 Config.withDefault("fallback"),
1314 (config) => config.parse(provider),
1315 Effect.flip
1316 )
1317
1318 assert.strictEqual(error.cause, cause)
1319 }))
1320
1321 it.effect("rejects present containers with incompatible shapes", () =>
1322 Effect.gen(function*() {
1323 const wrongStruct = yield* Config.schema(
1324 Schema.Struct({ value: Schema.optionalKey(Schema.String) }),
1325 "value"
1326 ).parse(ConfigProvider.fromUnknown({ value: [] })).pipe(Effect.flip)
1327 assert.ok(wrongStruct instanceof Config.ConfigError)
1328
1329 const wrongArray = yield* Config.schema(
1330 Schema.Array(Schema.String),
1331 "value"
1332 ).parse(ConfigProvider.fromUnknown({ value: {} })).pipe(Effect.flip)
1333 assert.ok(wrongArray instanceof Config.ConfigError)
1334 }))
1335 })
1336
1337 describe("nested", () => {
1338 describe("with fromUnknown", () => {
1339 it("prefixes a root config", async () => {
1340 const config = Config.String().pipe(Config.nested("a"))
1341
1342 await assertSuccess(
1343 config,
1344 ConfigProvider.fromUnknown({ a: "value" }),
1345 "value"
1346 )
1347 await assertFailure(
1348 config,
1349 ConfigProvider.fromUnknown({}),
1350 `Expected string
1351 at ["a"]`
1352 )
1353 })
1354
1355 it("composes a constructor path with a prefix", async () => {
1356 const config = Config.String("a").pipe(Config.nested("b"))
1357
1358 await assertSuccess(
1359 config,
1360 ConfigProvider.fromUnknown({ b: { a: "value" } }),
1361 "value"
1362 )
1363 await assertFailure(
1364 config,
1365 ConfigProvider.fromUnknown({}),
1366 `Expected string
1367 at ["b"]["a"]`
1368 )
1369 })
1370
1371 it("composes multiple prefixes from outermost to innermost", async () => {
1372 const config = Config.String("a").pipe(Config.nested("b"), Config.nested("c"))
1373
1374 await assertSuccess(
1375 config,
1376 ConfigProvider.fromUnknown({ c: { b: { a: "value" } } }),
1377 "value"
1378 )
1379 await assertFailure(
1380 config,
1381 ConfigProvider.fromUnknown({ c: { b: {} } }),
1382 `Expected string
1383 at ["c"]["b"]["a"]`
1384 )
1385 })
1386
1387 it("prefixes every child of an all product", async () => {
1388 const config = Config.all({
1389 host: Config.String("host"),
1390 port: Config.Number("port")
1391 }).pipe(Config.nested("database"))
1392
1393 await assertSuccess(
1394 config,
1395 ConfigProvider.fromUnknown({ database: { host: "localhost", port: "5432" } }),
1396 { host: "localhost", port: 5432 }
1397 )
1398 await assertFailure(
1399 config,
1400 ConfigProvider.fromUnknown({}),
1401 `Expected string
1402 at ["database"]["host"]`
1403 )
1404 })
1405 })
1406
1407 describe("with fromEnv", () => {
1408 it("prefixes a root config", async () => {
1409 const config = Config.String().pipe(Config.nested("a"))
1410
1411 await assertSuccess(
1412 config,
1413 ConfigProvider.fromEnv({ env: { a: "value" } }),
1414 "value"
1415 )
1416 await assertFailure(
1417 config,
1418 ConfigProvider.fromEnv({ env: {} }),
1419 `Expected string
1420 at ["a"]`
1421 )
1422 })
1423
1424 it("composes a constructor path with a prefix", async () => {
1425 const config = Config.String("a").pipe(Config.nested("b"))
1426
1427 await assertSuccess(
1428 config,
1429 ConfigProvider.fromEnv({ env: { "b_a": "value" } }),
1430 "value"
1431 )
1432 await assertFailure(
1433 config,
1434 ConfigProvider.fromEnv({ env: {} }),
1435 `Expected string
1436 at ["b"]["a"]`
1437 )
1438 })
1439
1440 it("composes multiple prefixes from outermost to innermost", async () => {
1441 const config = Config.String("a").pipe(Config.nested("b"), Config.nested("c"))
1442
1443 await assertSuccess(
1444 config,
1445 ConfigProvider.fromEnv({ env: { "c_b_a": "value" } }),
1446 "value"
1447 )
1448 await assertFailure(
1449 config,
1450 ConfigProvider.fromEnv({ env: { "c_b": "value" } }),
1451 `Expected string
1452 at ["c"]["b"]["a"]`
1453 )
1454 })
1455
1456 it("prefixes every child of an all product", async () => {
1457 const config = Config.all({
1458 host: Config.String("host"),
1459 port: Config.Number("port")
1460 }).pipe(Config.nested("database"))
1461
1462 await assertSuccess(
1463 config,
1464 ConfigProvider.fromEnv({ env: { "database_host": "localhost", "database_port": "5432" } }),
1465 { host: "localhost", port: 5432 }
1466 )
1467 await assertFailure(
1468 config,
1469 ConfigProvider.fromEnv({ env: {} }),
1470 `Expected string
1471 at ["database"]["host"]`
1472 )
1473 })
1474
1475 it("composes Config and provider prefixes without leaking provider paths into errors", async () => {
1476 const config = Config.String("host").pipe(Config.nested("database"))
1477 const provider = ConfigProvider.fromEnv({
1478 env: { app_database_host: "localhost" }
1479 }).pipe(ConfigProvider.nested("app"))
1480
1481 await assertSuccess(config, provider, "localhost")
1482 await assertFailure(
1483 config,
1484 ConfigProvider.fromEnv({ env: {} }).pipe(ConfigProvider.nested("app")),
1485 `Expected string
1486 at ["database"]["host"]`
1487 )
1488 })
1489
1490 it("preserves logical error paths through provider fallback", async () => {
1491 const provider = ConfigProvider.fromEnv({ env: { app_port: "abc" } }).pipe(
1492 ConfigProvider.orElse(ConfigProvider.fromEnv({ env: {} })),
1493 ConfigProvider.nested("app")
1494 )
1495
1496 await assertFailure(
1497 Config.Number("port"),
1498 provider,
1499 `Expected a string representing a finite number
1500 at ["port"]
1501Expected "Infinity" | "-Infinity" | "NaN"
1502 at ["port"]`
1503 )
1504 })
1505 })
1506 })
1507
1508 describe("unwrap", () => {
1509 it("combines a plain record of configs", async () => {
1510 const config = Config.unwrap({
1511 a: Config.schema(Schema.String, "a2")
1512 })
1513
1514 await assertSuccess(config, ConfigProvider.fromUnknown({ a2: "value" }), { a: "value" })
1515 })
1516
1517 it("recursively combines nested records", async () => {
1518 const config = Config.unwrap({
1519 a: {
1520 b: Config.schema(Schema.String, "b2")
1521 }
1522 })
1523
1524 await assertSuccess(
1525 config,
1526 ConfigProvider.fromUnknown({ b2: "value" }),
1527 { a: { b: "value" } }
1528 )
1529 })
1530 })
1531 })
1532
1533 describe("schema", () => {
1534 it("reports missing redacted input", async () => {
1535 await assertFailure(
1536 Config.schema(Schema.Redacted(Schema.Literal("secret")), "a"),
1537 ConfigProvider.fromUnknown({}),
1538 `Expected "secret"
1539 at ["a"]`
1540 )
1541 })
1542
1543 describe("built-in schema-backed constructors", () => {
1544 it("decodes supported boolean spellings", async () => {
1545 const provider = ConfigProvider.fromUnknown({
1546 a: "true",
1547 b: "false",
1548 c: "yes",
1549 d: "no",
1550 e: "on",
1551 f: "off",
1552 g: "1",
1553 h: "0",
1554 i: "y",
1555 j: "n",
1556 failure: "value"
1557 })
1558
1559 await assertSuccess(Config.Boolean("a"), provider, true)
1560 await assertSuccess(Config.Boolean("b"), provider, false)
1561 await assertSuccess(Config.Boolean("c"), provider, true)
1562 await assertSuccess(Config.Boolean("d"), provider, false)
1563 await assertSuccess(Config.Boolean("e"), provider, true)
1564 await assertSuccess(Config.Boolean("f"), provider, false)
1565 await assertSuccess(Config.Boolean("g"), provider, true)
1566 await assertSuccess(Config.Boolean("h"), provider, false)
1567 await assertSuccess(Config.Boolean("i"), provider, true)
1568 await assertSuccess(Config.Boolean("j"), provider, false)
1569 await assertFailure(
1570 Config.Boolean("failure"),
1571 provider,
1572 `Expected "true" | "yes" | "on" | "1" | "y" | "false" | "no" | "off" | "0" | "n"
1573 at ["failure"]`
1574 )
1575 })
1576
1577 it("decodes durations including infinities", async () => {
1578 const provider = ConfigProvider.fromUnknown({
1579 a: "1000 millis",
1580 b: "1 second",
1581 c: "Infinity",
1582 d: "-Infinity",
1583 failure: "value"
1584 })
1585
1586 await assertSuccess(Config.Duration("a"), provider, Duration.millis(1000))
1587 await assertSuccess(Config.Duration("b"), provider, Duration.seconds(1))
1588 await assertSuccess(Config.Duration("c"), provider, Duration.infinity)
1589 await assertSuccess(Config.Duration("d"), provider, Duration.negativeInfinity)
1590 await assertFailure(
1591 Config.Duration("failure"),
1592 provider,
1593 `Expected a valid Duration string
1594 at ["failure"]`
1595 )
1596 })
1597
1598 it("decodes exact byte sizes and reports invalid input", async () => {
1599 const provider = ConfigProvider.fromUnknown({
1600 huge: "9007199254740993 B",
1601 invalid: "10 MBi"
1602 })
1603
1604 await assertSuccess(Config.ByteSize("huge"), provider, ByteSize.bytes(9_007_199_254_740_993n))
1605 await assertFailure(
1606 Config.ByteSize("invalid"),
1607 provider,
1608 `Expected a valid ByteSize string\n at ["invalid"]`
1609 )
1610 })
1611
1612 it("validates port ranges", async () => {
1613 const provider = ConfigProvider.fromUnknown({
1614 a: "8080",
1615 failure: "-1"
1616 })
1617
1618 await assertSuccess(Config.Port("a"), provider, 8080)
1619 await assertFailure(
1620 Config.Port("failure"),
1621 provider,
1622 `Expected a value between 1 and 65535
1623 at ["failure"]`
1624 )
1625 })
1626
1627 it("validates log-level literals", async () => {
1628 const provider = ConfigProvider.fromUnknown({
1629 a: "Info",
1630 failure_1: "info",
1631 failure_2: "value"
1632 })
1633
1634 await assertSuccess(Config.LogLevel("a"), provider, "Info")
1635 await assertFailure(
1636 Config.LogLevel("failure_1"),
1637 provider,
1638 `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None"
1639 at ["failure_1"]`
1640 )
1641 await assertFailure(
1642 Config.LogLevel("failure_2"),
1643 provider,
1644 `Expected "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None"
1645 at ["failure_2"]`
1646 )
1647 })
1648
1649 describe("Record", () => {
1650 it("decodes object input", async () => {
1651 const config = Config.Record(Schema.String, Schema.String, "OTEL_RESOURCE_ATTRIBUTES")
1652
1653 await assertSuccess(
1654 config,
1655 ConfigProvider.fromUnknown({
1656 OTEL_RESOURCE_ATTRIBUTES: {
1657 "service.name": "my-service",
1658 "service.version": "1.0.0",
1659 "custom.attribute": "value"
1660 }
1661 }),
1662 {
1663 "service.name": "my-service",
1664 "service.version": "1.0.0",
1665 "custom.attribute": "value"
1666 }
1667 )
1668 })
1669
1670 it("decodes separated string input", async () => {
1671 const config = Config.Record(Schema.String, Schema.String, "OTEL_RESOURCE_ATTRIBUTES")
1672
1673 await assertSuccess(
1674 config,
1675 ConfigProvider.fromEnv({
1676 env: {
1677 OTEL_RESOURCE_ATTRIBUTES: "service.name=my-service,service.version=1.0.0,custom.attribute=value"
1678 }
1679 }),
1680 {
1681 "service.name": "my-service",
1682 "service.version": "1.0.0",
1683 "custom.attribute": "value"
1684 }
1685 )
1686 })
1687
1688 it("supports custom separators", async () => {
1689 const options = { separator: "&", keyValueSeparator: "==" }
1690 const input = "service.name==my-service&service.version==1.0.0&custom.attribute==value"
1691 const expected = {
1692 "service.name": "my-service",
1693 "service.version": "1.0.0",
1694 "custom.attribute": "value"
1695 }
1696
1697 await assertSuccess(
1698 Config.Record(Schema.String, Schema.String, options),
1699 ConfigProvider.fromUnknown(input),
1700 expected
1701 )
1702 await assertSuccess(
1703 Config.Record(Schema.String, Schema.String, "OTEL_RESOURCE_ATTRIBUTES", options),
1704 ConfigProvider.fromUnknown({ OTEL_RESOURCE_ATTRIBUTES: input }),
1705 expected
1706 )
1707 })
1708 })
1709 })
1710
1711 describe("materialization", () => {
1712 describe("Encoded shapes", () => {
1713 const scalarToStruct = Schema.String.pipe(
1714 Schema.decodeTo(
1715 Schema.Struct({ value: Schema.String }),
1716 SchemaTransformation.transform({
1717 decode: (value) => ({ value }),
1718 encode: ({ value }) => value
1719 })
1720 )
1721 )
1722 const structToScalar = Schema.Struct({ value: Schema.String }).pipe(
1723 Schema.decodeTo(
1724 Schema.String,
1725 SchemaTransformation.transform({
1726 decode: ({ value }) => value,
1727 encode: (value) => ({ value })
1728 })
1729 )
1730 )
1731
1732 it.effect("loads the encoded shape when it differs from the decoded shape", () =>
1733 Effect.gen(function*() {
1734 assert.deepStrictEqual(
1735 yield* Config.schema(scalarToStruct, "config").parse(
1736 ConfigProvider.fromUnknown({ config: "value" })
1737 ),
1738 { value: "value" }
1739 )
1740 assert.strictEqual(
1741 yield* Config.schema(structToScalar, "config").parse(
1742 ConfigProvider.fromUnknown({ config: { value: "value" } })
1743 ),
1744 "value"
1745 )
1746 }))
1747
1748 it.effect("loads encoded shapes recursively inside objects and arrays", () =>
1749 Effect.gen(function*() {
1750 const config = Config.schema(
1751 Schema.Struct({
1752 fromScalar: scalarToStruct,
1753 fromStruct: structToScalar,
1754 items: Schema.Array(scalarToStruct)
1755 })
1756 )
1757
1758 assert.deepStrictEqual(
1759 yield* config.parse(
1760 ConfigProvider.fromUnknown({
1761 fromScalar: "one",
1762 fromStruct: { value: "two" },
1763 items: ["three"]
1764 })
1765 ),
1766 {
1767 fromScalar: { value: "one" },
1768 fromStruct: "two",
1769 items: [{ value: "three" }]
1770 }
1771 )
1772 }))
1773
1774 it.effect("loads every union member from its encoded shape", () =>
1775 Effect.gen(function*() {
1776 const config = Config.schema(
1777 Schema.Union([scalarToStruct, structToScalar]),
1778 "config"
1779 )
1780
1781 assert.deepStrictEqual(
1782 yield* config.parse(ConfigProvider.fromUnknown({ config: "scalar" })),
1783 { value: "scalar" }
1784 )
1785 assert.strictEqual(
1786 yield* config.parse(
1787 ConfigProvider.fromUnknown({ config: { value: "struct" } })
1788 ),
1789 "struct"
1790 )
1791 }))
1792 })
1793
1794 describe("Objects", () => {
1795 it.effect("loads explicit properties even when only a fallback provider contains the child", () =>
1796 Effect.gen(function*() {
1797 const primary = ConfigProvider.make((path) =>
1798 Effect.succeed(
1799 path.length === 0
1800 ? ConfigProvider.makeRecord(new Set())
1801 : undefined
1802 )
1803 )
1804 const fallback = ConfigProvider.make((path) =>
1805 Effect.succeed(
1806 path.length === 1 && path[0] === "host"
1807 ? ConfigProvider.makeValue("localhost")
1808 : undefined
1809 )
1810 )
1811 const provider = ConfigProvider.orElse(primary, fallback)
1812
1813 assert.deepStrictEqual(
1814 yield* Config.schema(Schema.Struct({ host: Schema.String })).parse(provider),
1815 { host: "localhost" }
1816 )
1817 }))
1818
1819 it.effect("does not load advertised keys that are unrelated to the schema", () =>
1820 Effect.gen(function*() {
1821 const sourceError = new ConfigProvider.SourceError({ message: "unrelated key was loaded" })
1822 const provider = ConfigProvider.make((path) => {
1823 if (path.length === 0) {
1824 return Effect.succeed(ConfigProvider.makeRecord(new Set(["wanted", "unrelated"])))
1825 }
1826 if (path[0] === "wanted") {
1827 return Effect.succeed(ConfigProvider.makeValue("value"))
1828 }
1829 return Effect.fail(sourceError)
1830 })
1831
1832 assert.deepStrictEqual(
1833 yield* Config.schema(Schema.Struct({ wanted: Schema.String })).parse(provider),
1834 { wanted: "value" }
1835 )
1836 }))
1837
1838 it.effect("loads advertised keys only when they match an index signature", () =>
1839 Effect.gen(function*() {
1840 const sourceError = new ConfigProvider.SourceError({ message: "non-matching key was loaded" })
1841 const provider = ConfigProvider.make((path) => {
1842 if (path.length === 0) {
1843 return Effect.succeed(ConfigProvider.makeRecord(new Set(["wanted", "unrelated"])))
1844 }
1845 if (path[0] === "wanted") {
1846 return Effect.succeed(ConfigProvider.makeValue("value"))
1847 }
1848 return Effect.fail(sourceError)
1849 })
1850
1851 assert.deepStrictEqual(
1852 yield* Config.schema(Schema.Record(Schema.Literal("wanted"), Schema.String)).parse(provider),
1853 { wanted: "value" }
1854 )
1855 }))
1856
1857 it.effect("leaves separated record parsing to the explicit Config.Record constructor", () =>
1858 Effect.gen(function*() {
1859 const provider = ConfigProvider.fromEnv({
1860 env: {
1861 values: "first=1,second=2"
1862 }
1863 })
1864
1865 assert.deepStrictEqual(
1866 yield* Config.Record(Schema.String, Schema.Finite, "values").parse(provider),
1867 { first: 1, second: 2 }
1868 )
1869 }))
1870 })
1871
1872 describe("Arrays", () => {
1873 it.effect("preserves missing array positions as undefined values", () =>
1874 Effect.gen(function*() {
1875 const provider = ConfigProvider.make((path) => {
1876 if (path.length === 0) {
1877 return Effect.succeed(ConfigProvider.makeArray(2))
1878 }
1879 return Effect.succeed(
1880 path[0] === 0
1881 ? ConfigProvider.makeValue("value")
1882 : undefined
1883 )
1884 })
1885
1886 assert.deepStrictEqual(
1887 yield* Config.schema(Schema.Array(Schema.UndefinedOr(Schema.String))).parse(provider),
1888 ["value", undefined]
1889 )
1890 }))
1891
1892 it.effect("leaves scalar-to-array parsing to the explicit Config.Array constructor", () =>
1893 Effect.gen(function*() {
1894 const provider = ConfigProvider.fromEnv({
1895 env: {
1896 values: "1,2",
1897 values_0: "3"
1898 }
1899 })
1900
1901 assert.deepStrictEqual(
1902 yield* Config.Array(Schema.Finite, "values").parse(provider),
1903 [1, 2]
1904 )
1905 assert.deepStrictEqual(
1906 yield* Config.schema(Schema.Array(Schema.Finite), "values").parse(provider),
1907 [3]
1908 )
1909 }))
1910
1911 it.effect("supports options with and without a path", () =>
1912 Effect.gen(function*() {
1913 assert.deepStrictEqual(
1914 yield* Config.Array(Schema.Finite, { separator: ";" }).parse(ConfigProvider.fromUnknown("1;2")),
1915 [1, 2]
1916 )
1917 assert.deepStrictEqual(
1918 yield* Config.Array(Schema.Finite, "values", { separator: ";" }).parse(
1919 ConfigProvider.fromUnknown({ values: "1;2" })
1920 ),
1921 [1, 2]
1922 )
1923 }))
1924 })
1925
1926 describe("Opaque schemas", () => {
1927 const unsupported = [
1928 ["Any", Schema.Any],
1929 ["Unknown", Schema.Unknown],
1930 ["ObjectKeyword", Schema.ObjectKeyword],
1931 ["Json", Schema.Json],
1932 ["MutableJson", Schema.MutableJson]
1933 ] as const
1934
1935 for (const [name, schema] of unsupported) {
1936 it(`rejects Schema.${name}`, () => {
1937 assert.throws(
1938 () => Config.schema(schema),
1939 /Config\.schema does not support opaque StringTree encodings/
1940 )
1941 })
1942 }
1943
1944 it("rejects opaque shapes nested in objects", () => {
1945 assert.throws(
1946 () => Config.schema(Schema.Struct({ value: Schema.Unknown })),
1947 /Config\.schema does not support opaque StringTree encodings/
1948 )
1949 })
1950
1951 it("rejects opaque union members", () => {
1952 assert.throws(
1953 () => Config.schema(Schema.Union([Schema.String, Schema.Unknown])),
1954 /Config\.schema does not support opaque StringTree encodings/
1955 )
1956 })
1957
1958 it("rejects opaque shapes behind suspensions", () => {
1959 assert.throws(
1960 () => Config.schema(Schema.suspend(() => Schema.Unknown)),
1961 /Config\.schema does not support opaque StringTree encodings/
1962 )
1963 })
1964
1965 it.effect("supports declarations with concrete StringTree encodings", () =>
1966 Effect.gen(function*() {
1967 const provider = ConfigProvider.fromUnknown({ value: "https://example.com" })
1968 const value = yield* Config.schema(Schema.URL, "value").parse(provider)
1969
1970 assert.strictEqual(value.href, "https://example.com/")
1971 }))
1972
1973 it.effect("supports arbitrary JSON encoded in a scalar string", () =>
1974 Effect.gen(function*() {
1975 const provider = ConfigProvider.fromUnknown({ value: `{"nested":[1,true]}` })
1976 const value = yield* Config.schema(Schema.fromJsonString(Schema.Json), "value").parse(provider)
1977
1978 assert.deepStrictEqual(value, { nested: [1, true] })
1979 }))
1980 })
1981
1982 describe("Union", () => {
1983 it("materializes each member independently before applying first-match semantics", async () => {
1984 const config = Config.schema(
1985 Schema.Union([
1986 Schema.Struct({ child: Schema.String }),
1987 Schema.String
1988 ]),
1989 "value"
1990 )
1991 const provider = ConfigProvider.fromEnv({
1992 env: {
1993 value: "scalar",
1994 value_child: "object"
1995 }
1996 })
1997
1998 await assertSuccess(config, provider, { child: "object" })
1999 })
2000
2001 it("preserves first-match semantics when the scalar member is declared first", async () => {
2002 const config = Config.schema(
2003 Schema.Union([
2004 Schema.String,
2005 Schema.Struct({ child: Schema.String })
2006 ]),
2007 "value"
2008 )
2009 const provider = ConfigProvider.fromEnv({
2010 env: {
2011 value: "scalar",
2012 value_child: "object"
2013 }
2014 })
2015
2016 await assertSuccess(config, provider, "scalar")
2017 })
2018
2019 it("reports oneOf ambiguity without exposing the internal cursor", async () => {
2020 const config = Config.schema(
2021 Schema.Union([
2022 Schema.Struct({ child: Schema.String }),
2023 Schema.String
2024 ], { mode: "oneOf" }),
2025 ["database", "value"]
2026 )
2027 const provider = ConfigProvider.fromEnv({
2028 env: {
2029 database_value: "scalar",
2030 database_value_child: "object"
2031 }
2032 })
2033
2034 await assertFailure(
2035 config,
2036 provider,
2037 `Expected exactly one member to match
2038 at ["database"]["value"]`
2039 )
2040 })
2041
2042 it.effect("recovers an absent sibling of a successfully decoded union", () =>
2043 Effect.gen(function*() {
2044 const config = Config.all({
2045 selected: Config.schema(
2046 Schema.Union([
2047 Schema.Undefined,
2048 Schema.Struct({ child: Schema.String })
2049 ]),
2050 "value"
2051 ),
2052 required: Config.String("required")
2053 }).pipe(
2054 Config.withDefault({
2055 selected: undefined,
2056 required: "default"
2057 })
2058 )
2059 const provider = ConfigProvider.fromEnv({
2060 env: {
2061 value_child: "present"
2062 }
2063 })
2064
2065 assert.deepStrictEqual(
2066 yield* config.parse(provider),
2067 { selected: undefined, required: "default" }
2068 )
2069 }))
2070
2071 it.effect("applies checks attached to the original union", () =>
2072 Effect.gen(function*() {
2073 const schema = Schema.Union([
2074 Schema.Struct({ child: Schema.String }),
2075 Schema.String
2076 ]).check(
2077 Schema.makeFilter((value) =>
2078 typeof value === "string"
2079 ? new SchemaIssue.InvalidValue({ message: "union check failed" })
2080 : undefined
2081 )
2082 )
2083 const error = yield* Config.schema(schema, "value").parse(
2084 ConfigProvider.fromUnknown({ value: "scalar" })
2085 ).pipe(Effect.flip)
2086
2087 assert.strictEqual(
2088 error.cause.message,
2089 `union check failed
2090 at ["value"]`
2091 )
2092 }))
2093
2094 it.effect("propagates SourceError defects instead of trying another member", () =>
2095 Effect.gen(function*() {
2096 const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" })
2097 const provider = ConfigProvider.make((path) => {
2098 if (path.length === 1 && path[0] === "value") {
2099 return Effect.succeed(ConfigProvider.makeRecord(new Set(["child"]), "scalar"))
2100 }
2101 return Effect.fail(sourceError)
2102 })
2103 const config = Config.schema(
2104 Schema.Union([
2105 Schema.Struct({ child: Schema.String }),
2106 Schema.String
2107 ]),
2108 "value"
2109 )
2110
2111 const error = yield* config.parse(provider).pipe(Effect.flip)
2112 assert.strictEqual(error.cause, sourceError)
2113 }))
2114 })
2115 })
2116
2117 describe("fromEnv provider", () => {
2118 it("loads root, flat, and nested paths", async () => {
2119 await assertSuccess(
2120 Config.schema(Schema.String, "a"),
2121 ConfigProvider.fromEnv({ env: { a: "value" } }),
2122 "value"
2123 )
2124 await assertSuccess(
2125 Config.schema(Schema.String, ["a", "b"]),
2126 ConfigProvider.fromEnv({ env: { "a_b": "value" } }),
2127 "value"
2128 )
2129 await assertSuccess(
2130 Config.schema(Schema.UndefinedOr(Schema.String)),
2131 ConfigProvider.fromEnv({ env: {} }),
2132 undefined
2133 )
2134 await assertSuccess(
2135 Config.schema(Schema.UndefinedOr(Schema.String), "a"),
2136 ConfigProvider.fromEnv({ env: {} }),
2137 undefined
2138 )
2139 })
2140
2141 describe("node precedence", () => {
2142 it("uses a co-located scalar instead of object children for a leaf schema", async () => {
2143 const schema = Schema.Struct({ a: Schema.Number })
2144 const config = Config.schema(schema)
2145
2146 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2" } }), { a: 1 })
2147 })
2148
2149 it("uses a co-located scalar instead of array children for a leaf schema", async () => {
2150 const schema = Schema.Struct({ a: Schema.Number })
2151 const config = Config.schema(schema)
2152
2153 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_0": "2" } }), { a: 1 })
2154 })
2155
2156 it("prefers object children when a node can be both object and array", async () => {
2157 const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.Number }) })
2158 const config = Config.schema(schema)
2159
2160 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", "a_b": "2", "a_0": "3" } }), {
2161 a: { b: 2 }
2162 })
2163 })
2164 })
2165
2166 it("decodes Null", async () => {
2167 const schema = Schema.Null
2168 const config = Config.schema(schema, "a")
2169
2170 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "null" } }), null)
2171 await assertFailure(
2172 config,
2173 ConfigProvider.fromEnv({ env: {} }),
2174 `Expected "null"
2175 at ["a"]`
2176 )
2177 })
2178
2179 it("decodes String and reports absence", async () => {
2180 const schema = Schema.String
2181 const config = Config.schema(schema, "a")
2182
2183 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a")
2184 await assertFailure(
2185 config,
2186 ConfigProvider.fromEnv({ env: {} }),
2187 `Expected string
2188 at ["a"]`
2189 )
2190 })
2191
2192 it("decodes Number and reports absence", async () => {
2193 const schema = Schema.Number
2194 const config = Config.schema(schema, "a")
2195
2196 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1)
2197 await assertFailure(
2198 config,
2199 ConfigProvider.fromEnv({ env: {} }),
2200 `Expected string | "Infinity" | "-Infinity" | "NaN"
2201 at ["a"]`
2202 )
2203 })
2204
2205 it("decodes Finite and reports absence", async () => {
2206 const schema = Schema.Finite
2207 const config = Config.schema(schema, "a")
2208
2209 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1)
2210 await assertFailure(
2211 config,
2212 ConfigProvider.fromEnv({ env: {} }),
2213 `Expected string
2214 at ["a"]`
2215 )
2216 })
2217
2218 it("decodes Int and reports absence", async () => {
2219 const schema = Schema.Int
2220 const config = Config.schema(schema, "a")
2221
2222 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1)
2223 await assertFailure(
2224 config,
2225 ConfigProvider.fromEnv({ env: {} }),
2226 `Expected string
2227 at ["a"]`
2228 )
2229 })
2230
2231 it("decodes Boolean and reports absence", async () => {
2232 const schema = Schema.Boolean
2233 const config = Config.schema(schema, "a")
2234
2235 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "true" } }), true)
2236 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "false" } }), false)
2237 await assertFailure(
2238 config,
2239 ConfigProvider.fromEnv({ env: {} }),
2240 `Expected "true" | "false"
2241 at ["a"]`
2242 )
2243 })
2244
2245 describe("Struct", () => {
2246 it("decodes required properties", async () => {
2247 const schema = Schema.Struct({ a: Schema.Number })
2248 const config = Config.schema(schema)
2249
2250 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 })
2251 })
2252
2253 it("omits absent optionalKey properties", async () => {
2254 const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) })
2255 const config = Config.schema(schema)
2256
2257 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 })
2258 await assertSuccess(config, ConfigProvider.fromEnv({ env: { unrelated: "value" } }), {})
2259 })
2260
2261 it("omits absent optional properties", async () => {
2262 const config = Config.schema(
2263 Schema.Struct({ a: Schema.optional(Schema.Number) })
2264 )
2265
2266 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 })
2267 await assertSuccess(config, ConfigProvider.fromEnv({ env: { unrelated: "value" } }), {})
2268 })
2269
2270 it("decodes literal properties", async () => {
2271 const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) })
2272 const config = Config.schema(schema)
2273
2274 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "b" } }), { a: "b" })
2275 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "c" } }), { a: "c" })
2276 })
2277
2278 it("decodes indexed array properties without treating co-located scalars as arrays", async () => {
2279 const schema = Schema.Struct({ a: Schema.Array(Schema.Number) })
2280 const config = Config.schema(schema)
2281
2282 await assertFailure(
2283 config,
2284 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
2285 `Expected array
2286 at ["a"]`
2287 )
2288 await assertFailure(
2289 config,
2290 ConfigProvider.fromEnv({ env: { a: "1" } }),
2291 `Expected array
2292 at ["a"]`
2293 )
2294 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1" } }), { a: [1] })
2295 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] })
2296 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", a_0: "2" } }), { a: [2] })
2297 await assertFailure(
2298 config,
2299 ConfigProvider.fromEnv({ env: {} }),
2300 `Expected object`
2301 )
2302 })
2303 })
2304
2305 it("decodes and validates Record values", async () => {
2306 const schema = Schema.Record(Schema.String, Schema.Finite)
2307 const config = Config.schema(schema)
2308
2309 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), { a: 1 })
2310 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1", b: "2" } }), { a: 1, b: 2 })
2311 await assertFailure(
2312 config,
2313 ConfigProvider.fromEnv({ env: { a: "1", b: "value" } }),
2314 `Expected a string representing a finite number
2315 at ["b"]`
2316 )
2317 })
2318
2319 describe("Tuple", () => {
2320 it("rejects a scalar where an empty tuple is expected", async () => {
2321 const schema = Schema.Struct({ a: Schema.Tuple([]) })
2322 const config = Config.schema(schema)
2323
2324 await assertFailure(
2325 config,
2326 ConfigProvider.fromEnv({ env: { a: "" }, preserveEmptyStrings: true }),
2327 `Expected array
2328 at ["a"]`
2329 )
2330 })
2331
2332 it("rejects scalar tuple input", async () => {
2333 const schema = Schema.Struct({ a: Schema.Tuple([Schema.Number]) })
2334 const config = Config.schema(schema)
2335
2336 await assertFailure(
2337 config,
2338 ConfigProvider.fromEnv({ env: { a: "1" } }),
2339 `Expected array
2340 at ["a"]`
2341 )
2342 })
2343
2344 it("requires and validates every tuple element", async () => {
2345 const schema = Schema.Struct({ a: Schema.Tuple([Schema.String, Schema.Finite]) })
2346 const config = Config.schema(schema)
2347
2348 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "2" } }), { a: ["a", 2] })
2349 await assertFailure(
2350 config,
2351 ConfigProvider.fromEnv({ env: { a: "a" } }),
2352 `Expected array
2353 at ["a"]`
2354 )
2355 await assertFailure(
2356 config,
2357 ConfigProvider.fromEnv({ env: { a_0: "a", a_1: "value" } }),
2358 `Expected a string representing a finite number
2359 at ["a"][1]`
2360 )
2361 })
2362 })
2363
2364 it("decodes indexed Array input and rejects scalar input", async () => {
2365 const schema = Schema.Struct({ a: Schema.Array(Schema.Finite) })
2366 const config = Config.schema(schema)
2367
2368 await assertFailure(
2369 config,
2370 ConfigProvider.fromEnv({ env: { a: "1,2,3" } }),
2371 `Expected array
2372 at ["a"]`
2373 )
2374 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "2" } }), { a: [1, 2] })
2375 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a", a_0: "1" } }), { a: [1] })
2376 await assertFailure(
2377 config,
2378 ConfigProvider.fromEnv({ env: { a_0: "1", a_2: "2" } }),
2379 `Expected string
2380 at ["a"][1]`
2381 )
2382 await assertFailure(
2383 config,
2384 ConfigProvider.fromEnv({ env: { a_0: "1", a_1: "value" } }),
2385 `Expected a string representing a finite number
2386 at ["a"][1]`
2387 )
2388 })
2389
2390 describe("Union", () => {
2391 it("decodes literal unions", async () => {
2392 const schema = Schema.Struct({ a: Schema.Literals(["a", "b"]) })
2393 const config = Config.schema(schema)
2394
2395 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" })
2396 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "b" } }), { a: "b" })
2397 })
2398
2399 it("uses first-match semantics by default", async () => {
2400 const schema = Schema.Union([
2401 Schema.Struct({ a: Schema.String }),
2402 Schema.Struct({ b: Schema.Number })
2403 ])
2404 const config = Config.schema(schema)
2405
2406 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" })
2407 await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 })
2408 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }), { a: "a" })
2409 })
2410
2411 it("enforces exactly one match in oneOf mode", async () => {
2412 const schema = Schema.Union([
2413 Schema.Struct({ a: Schema.String }),
2414 Schema.Struct({ b: Schema.Number })
2415 ], { mode: "oneOf" })
2416 const config = Config.schema(schema)
2417
2418 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), { a: "a" })
2419 await assertSuccess(config, ConfigProvider.fromEnv({ env: { b: "1" } }), { b: 1 })
2420 await assertFailure(
2421 config,
2422 ConfigProvider.fromEnv({ env: { a: "a", b: "1" } }),
2423 "Expected exactly one member to match"
2424 )
2425 })
2426
2427 it("decodes Number before String", async () => {
2428 const schema = Schema.Union([Schema.Number, Schema.String])
2429 const config = Config.schema(schema, "a")
2430
2431 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1)
2432 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a")
2433 })
2434
2435 it("still decodes numeric input when String is listed first", async () => {
2436 const schema = Schema.Union([Schema.String, Schema.Number])
2437 const config = Config.schema(schema, "a")
2438
2439 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), 1)
2440 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "a" } }), "a")
2441 })
2442 })
2443
2444 it("reports Int validation errors", async () => {
2445 const schema = Schema.Redacted(Schema.Int)
2446 const config = Config.schema(schema, "a")
2447
2448 await assertSuccess(config, ConfigProvider.fromEnv({ env: { a: "1" } }), Redacted.make(1))
2449 await assertFailure(
2450 config,
2451 ConfigProvider.fromEnv({ env: {} }),
2452 `Expected string
2453 at ["a"]`
2454 )
2455 await assertFailure(
2456 config,
2457 ConfigProvider.fromEnv({ env: { a: "1.1" } }),
2458 `Expected an integer
2459 at ["a"]`
2460 )
2461 })
2462 })
2463
2464 describe("fromUnknown provider", () => {
2465 it("loads root, flat, and nested paths", async () => {
2466 await assertSuccess(
2467 Config.schema(Schema.String, []),
2468 ConfigProvider.fromUnknown("value"),
2469 "value"
2470 )
2471 await assertSuccess(
2472 Config.schema(Schema.String, "a"),
2473 ConfigProvider.fromUnknown({ a: "value" }),
2474 "value"
2475 )
2476 await assertSuccess(
2477 Config.schema(Schema.String, ["a", "b"]),
2478 ConfigProvider.fromUnknown({ a: { b: "value" } }),
2479 "value"
2480 )
2481 })
2482
2483 it("decodes Undefined", async () => {
2484 const schema = Schema.Undefined
2485 const config = Config.schema(schema)
2486
2487 await assertSuccess(config, ConfigProvider.fromUnknown(undefined), undefined)
2488 await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected undefined`)
2489 })
2490
2491 it("decodes Null", async () => {
2492 const schema = Schema.Null
2493 const config = Config.schema(schema)
2494
2495 await assertSuccess(config, ConfigProvider.fromUnknown("null"), null)
2496 await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "null"`)
2497 })
2498
2499 it("decodes String and rejects object input", async () => {
2500 const schema = Schema.String
2501 const config = Config.schema(schema)
2502
2503 await assertSuccess(config, ConfigProvider.fromUnknown("value"), "value")
2504 await assertFailure(config, ConfigProvider.fromUnknown({}), `Expected string`)
2505 })
2506
2507 it("decodes Number and rejects invalid input", async () => {
2508 const schema = Schema.Number
2509 const config = Config.schema(schema)
2510
2511 await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1)
2512 await assertFailure(
2513 config,
2514 ConfigProvider.fromUnknown("a"),
2515 `Expected a string representing a finite number
2516Expected "Infinity" | "-Infinity" | "NaN"`
2517 )
2518 })
2519
2520 it("decodes Finite and rejects invalid input", async () => {
2521 const schema = Schema.Finite
2522 const config = Config.schema(schema)
2523
2524 await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1)
2525 await assertFailure(
2526 config,
2527 ConfigProvider.fromUnknown("a"),
2528 `Expected a string representing a finite number`
2529 )
2530 })
2531
2532 it("decodes Int and rejects invalid input", async () => {
2533 const schema = Schema.Int
2534 const config = Config.schema(schema)
2535
2536 await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1)
2537 await assertFailure(
2538 config,
2539 ConfigProvider.fromUnknown("a"),
2540 `Expected a string representing a finite number`
2541 )
2542 })
2543
2544 it("decodes Boolean and rejects invalid input", async () => {
2545 const schema = Schema.Boolean
2546 const config = Config.schema(schema)
2547
2548 await assertSuccess(config, ConfigProvider.fromUnknown("true"), true)
2549 await assertSuccess(config, ConfigProvider.fromUnknown("false"), false)
2550 await assertFailure(config, ConfigProvider.fromUnknown("a"), `Expected "true" | "false"`)
2551 })
2552
2553 describe("Struct", () => {
2554 it("requires and validates required properties", async () => {
2555 const schema = Schema.Struct({ a: Schema.Finite })
2556 const config = Config.schema(schema)
2557
2558 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 })
2559 await assertFailure(
2560 config,
2561 ConfigProvider.fromUnknown({}),
2562 `Missing key
2563 at ["a"]`
2564 )
2565 await assertFailure(
2566 config,
2567 ConfigProvider.fromUnknown({ a: "value" }),
2568 `Expected a string representing a finite number
2569 at ["a"]`
2570 )
2571 })
2572
2573 it("omits absent optionalKey properties", async () => {
2574 const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Number) })
2575 const config = Config.schema(schema)
2576
2577 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 })
2578 await assertSuccess(config, ConfigProvider.fromUnknown({}), {})
2579 })
2580
2581 it("omits absent optional properties", async () => {
2582 const config = Config.schema(
2583 Schema.Struct({ a: Schema.optional(Schema.Number) })
2584 )
2585
2586 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 })
2587 await assertSuccess(config, ConfigProvider.fromUnknown({}), {})
2588 })
2589
2590 it("decodes literal properties", async () => {
2591 const schema = Schema.Struct({ a: Schema.Literals(["b", "c"]) })
2592 const config = Config.schema(schema)
2593
2594 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "b" }), { a: "b" })
2595 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "c" }), { a: "c" })
2596 })
2597
2598 it("rejects scalar values for array properties", async () => {
2599 const schema = Schema.Struct({ a: Schema.Array(Schema.Number) })
2600 const config = Config.schema(schema)
2601
2602 await assertFailure(
2603 config,
2604 ConfigProvider.fromUnknown({ a: "" }),
2605 `Missing key
2606 at ["a"]`
2607 )
2608 await assertFailure(
2609 config,
2610 ConfigProvider.fromUnknown({ a: "" }, { preserveEmptyStrings: true }),
2611 `Expected array
2612 at ["a"]`
2613 )
2614 await assertFailure(
2615 config,
2616 ConfigProvider.fromUnknown({ a: "1" }),
2617 `Expected array
2618 at ["a"]`
2619 )
2620 })
2621 })
2622
2623 it("decodes and validates Record values", async () => {
2624 const schema = Schema.Record(Schema.String, Schema.Finite)
2625 const config = Config.schema(schema)
2626
2627 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: 1 })
2628 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", b: "2" }), { a: 1, b: 2 })
2629 await assertFailure(
2630 config,
2631 ConfigProvider.fromUnknown({ a: "1", b: "value" }),
2632 `Expected a string representing a finite number
2633 at ["b"]`
2634 )
2635 })
2636
2637 describe("Tuple", () => {
2638 it("accepts array tuple input and rejects scalar input", async () => {
2639 const schema = Schema.Tuple([Schema.Number])
2640 const config = Config.schema(schema)
2641
2642 await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1])
2643 await assertFailure(config, ConfigProvider.fromUnknown("1"), `Expected array`)
2644 })
2645
2646 it("requires and validates every tuple element", async () => {
2647 const schema = Schema.Tuple([Schema.String, Schema.Finite])
2648 const config = Config.schema(schema)
2649
2650 await assertSuccess(config, ConfigProvider.fromUnknown(["a", "2"]), ["a", 2])
2651 await assertFailure(
2652 config,
2653 ConfigProvider.fromUnknown(["a"]),
2654 `Missing key
2655 at [1]`
2656 )
2657 await assertFailure(
2658 config,
2659 ConfigProvider.fromUnknown(["a", "value"]),
2660 `Expected a string representing a finite number
2661 at [1]`
2662 )
2663 })
2664 })
2665
2666 it("accepts array input and rejects scalar Array input", async () => {
2667 const schema = Schema.Array(Schema.Finite)
2668 const config = Config.schema(schema)
2669
2670 await assertSuccess(config, ConfigProvider.fromUnknown(["1"]), [1])
2671 await assertFailure(config, ConfigProvider.fromUnknown("1"), `Expected array`)
2672 await assertSuccess(config, ConfigProvider.fromUnknown(["1", "2"]), [1, 2])
2673 await assertFailure(
2674 config,
2675 ConfigProvider.fromUnknown(["1", "value"]),
2676 `Expected a string representing a finite number
2677 at [1]`
2678 )
2679 })
2680
2681 describe("Union", () => {
2682 it("decodes literal unions", async () => {
2683 const schema = Schema.Literals(["a", "b"])
2684 const config = Config.schema(schema)
2685
2686 await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a")
2687 await assertSuccess(config, ConfigProvider.fromUnknown("b"), "b")
2688 })
2689
2690 it("uses first-match semantics by default", async () => {
2691 const schema = Schema.Union([
2692 Schema.Struct({ a: Schema.String }),
2693 Schema.Struct({ b: Schema.Number })
2694 ])
2695 const config = Config.schema(schema)
2696
2697 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" })
2698 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 })
2699 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a", b: "1" }), { a: "a" })
2700 })
2701
2702 it("enforces exactly one match in oneOf mode", async () => {
2703 const schema = Schema.Union([
2704 Schema.Struct({ a: Schema.String }),
2705 Schema.Struct({ b: Schema.Number })
2706 ], { mode: "oneOf" })
2707 const config = Config.schema(schema)
2708
2709 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "a" }), { a: "a" })
2710 await assertSuccess(config, ConfigProvider.fromUnknown({ b: "1" }), { b: 1 })
2711 await assertFailure(
2712 config,
2713 ConfigProvider.fromUnknown({ a: "a", b: "1" }),
2714 "Expected exactly one member to match"
2715 )
2716 })
2717
2718 it("decodes Number before String", async () => {
2719 const schema = Schema.Union([Schema.Number, Schema.String])
2720 const config = Config.schema(schema)
2721
2722 await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1)
2723 await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a")
2724 })
2725
2726 it("still decodes numeric input when String is listed first", async () => {
2727 const schema = Schema.Union([Schema.String, Schema.Number])
2728 const config = Config.schema(schema)
2729
2730 await assertSuccess(config, ConfigProvider.fromUnknown("1"), 1)
2731 await assertSuccess(config, ConfigProvider.fromUnknown("a"), "a")
2732 })
2733 })
2734
2735 it("decodes recursive suspended schemas", async () => {
2736 interface A {
2737 readonly a: string
2738 readonly as: ReadonlyArray<A>
2739 }
2740 const schema = Schema.Struct({
2741 a: Schema.String,
2742 as: Schema.Array(Schema.suspend((): Schema.Codec<A> => schema))
2743 })
2744 const config = Config.schema(schema)
2745
2746 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [] }), { a: "1", as: [] })
2747 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1", as: [{ a: "2", as: [] }] }), {
2748 a: "1",
2749 as: [{ a: "2", as: [] }]
2750 })
2751 })
2752
2753 it("reports nested Int validation errors", async () => {
2754 const schema = Schema.Struct({ a: Schema.Redacted(Schema.Int) })
2755 const config = Config.schema(schema)
2756
2757 await assertSuccess(config, ConfigProvider.fromUnknown({ a: "1" }), { a: Redacted.make(1) })
2758 await assertFailure(
2759 config,
2760 ConfigProvider.fromUnknown({}),
2761 `Missing key
2762 at ["a"]`
2763 )
2764 await assertFailure(
2765 config,
2766 ConfigProvider.fromUnknown({ a: "1.1" }),
2767 `Expected an integer
2768 at ["a"]`
2769 )
2770 })
2771
2772 it("decodes nested URL values", async () => {
2773 const schema = Schema.Struct({ a: Schema.URL })
2774 const config = Config.schema(schema)
2775
2776 await assertSuccess(
2777 config,
2778 ConfigProvider.fromUnknown({ a: "https://example.com" }),
2779 { a: new URL("https://example.com") }
2780 )
2781 })
2782 })
2783 })
2784})