@jeengbe/config 0.0.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/env.mjs CHANGED
@@ -1,54 +1,91 @@
1
+ import { ValidationResult, arrayIncludes, collectValidationResults } from "./validation.mjs";
1
2
  import { EnvNode, ScalarEnvNode } from "./ast.mjs";
2
- import { arrayIncludes, combineValidationResults } from "./validation.mjs";
3
3
  import { Either } from "@jeengbe/prelude";
4
4
 
5
5
  //#region src/env.ts
6
6
  const env = {
7
7
  string(key, defaultValue) {
8
- return env.custom(key, (value) => Either.right(value), defaultValue);
8
+ return env.scalar(key, (value) => ValidationResult.success({
9
+ value,
10
+ defaulted: []
11
+ }), defaultValue);
9
12
  },
10
13
  number(key, defaultValue) {
11
- return env.custom(key, (value) => {
12
- if (/^-?\d+(?:\.\d+)?$/.test(value)) return Either.right(Number(value));
13
- return Either.left(["invalid number"]);
14
+ return env.scalar(key, (value, path) => {
15
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return ValidationResult.success({
16
+ value: Number(value),
17
+ defaulted: []
18
+ });
19
+ return ValidationResult.fail({ errors: [{
20
+ path,
21
+ key,
22
+ message: "invalid number",
23
+ value
24
+ }] });
14
25
  }, defaultValue);
15
26
  },
16
27
  boolean(key, defaultValue) {
17
- return env.custom(key, (value) => {
18
- if (value.toLowerCase() === "true") return Either.right(true);
19
- if (value.toLowerCase() === "false") return Either.right(false);
20
- return Either.left(["invalid boolean (must be 'true' or 'false')"]);
28
+ return env.scalar(key, (value, path) => {
29
+ if (value.toLowerCase() === "true") return ValidationResult.success({
30
+ value: true,
31
+ defaulted: []
32
+ });
33
+ if (value.toLowerCase() === "false") return ValidationResult.success({
34
+ value: false,
35
+ defaulted: []
36
+ });
37
+ return ValidationResult.fail({ errors: [{
38
+ path,
39
+ key,
40
+ message: "invalid boolean",
41
+ formatHint: "must be 'true' or 'false'",
42
+ value
43
+ }] });
21
44
  }, defaultValue);
22
45
  },
23
46
  enum(key, values, defaultValue) {
24
- return env.custom(key, (value) => {
25
- if (arrayIncludes(values, value)) return Either.right(value);
26
- return Either.left([`invalid enum value (must be one of: ${values.map((v) => `'${v}'`).join(", ")})`]);
47
+ return env.scalar(key, (value, path) => {
48
+ if (arrayIncludes(values, value)) return ValidationResult.success({
49
+ value,
50
+ defaulted: []
51
+ });
52
+ return ValidationResult.fail({ errors: [{
53
+ path,
54
+ key,
55
+ message: "invalid enum value",
56
+ formatHint: `must be one of: ${values.map((v) => `'${v}'`).join(", ")}`,
57
+ value
58
+ }] });
27
59
  }, defaultValue);
28
60
  },
29
- custom(key, transform, defaultValue) {
30
- return new ScalarEnvNode(key, (value) => {
31
- if (value === void 0) {
32
- if (defaultValue !== void 0) return Either.right(defaultValue);
33
- return Either.left(["required"]);
34
- }
35
- return transform(value);
36
- });
37
- },
38
61
  array(itemType, defaultValue) {
39
- return new EnvNode((path, loadValue) => {
40
- const value = loadValue(itemType.key);
62
+ return env.scalar(itemType.key, (value, path) => {
63
+ if (value === "") return ValidationResult.success({
64
+ value: [],
65
+ defaulted: []
66
+ });
67
+ return collectValidationResults(...value.split(",").map((v) => v.trim() || void 0).map((v, i) => itemType.validate((k) => k === itemType.key ? v : /* v8 ignore next */ void 0, `${path}.${i}`)));
68
+ }, defaultValue);
69
+ },
70
+ scalar(key, transform, defaultValue) {
71
+ return new ScalarEnvNode(key, (value, path) => {
41
72
  if (value === void 0) {
42
- if (defaultValue !== void 0) return Either.right(defaultValue);
43
- return Either.left([`${itemType.key} (${path}): required`]);
73
+ if (defaultValue !== void 0) return ValidationResult.success({
74
+ value: defaultValue,
75
+ defaulted: [{
76
+ path,
77
+ key,
78
+ defaultValue
79
+ }]
80
+ });
81
+ return Either.left("required");
44
82
  }
45
- if (value === "") return Either.right([]);
46
- return combineValidationResults(...value.split(",").map((v) => v.trim() || void 0).map((v, i) => itemType.validate(`${path}.${i}`, (k) => k === itemType.key ? v : void 0)));
83
+ return transform(value, path);
47
84
  });
48
85
  },
49
86
  discriminate(discriminatorKey, discriminatorValueType, mapping) {
50
- return new EnvNode((path, loadValue) => {
51
- return discriminatorValueType.validate(path, loadValue).flatMap((discriminatorValue) => {
87
+ return new EnvNode((loadValue, path) => {
88
+ return discriminatorValueType.validate(loadValue, `${path}.${discriminatorKey}`).flatMap((discriminatorValue) => {
52
89
  return resolveNode(path, mapping[discriminatorValue] ?? {}, loadValue).map((mappingValues) => ({
53
90
  [discriminatorKey]: discriminatorValue,
54
91
  ...mappingValues
@@ -56,15 +93,18 @@ const env = {
56
93
  });
57
94
  });
58
95
  },
59
- load(spec) {
60
- return resolveNode("$", spec, (key) => process.env[key]?.trim() || void 0).getOrElse((errors) => {
61
- throw new Error(`Failed to load config: ${errors.join(", ")}`);
62
- });
96
+ parse(spec, env = process.env) {
97
+ return resolveNode("$", spec, (key) => env[key]?.trim());
98
+ },
99
+ load(spec, env = process.env) {
100
+ return this.parse(spec, env).fold((failure) => {
101
+ throw new Error(`Environment validation failed:\n${failure.errors.map((e) => ` ${e.path} (${e.key}): ${e.message} (${[e.formatHint, `got: '${String(e.value ?? "<not provided>")}'`].filter((x) => x).join("; ")})`).join("\n")}`);
102
+ }, ({ value }) => value);
63
103
  }
64
104
  };
65
105
  function resolveNode(path, spec, loadValue) {
66
- if (spec instanceof EnvNode) return spec.validate(path, loadValue);
67
- return combineValidationResults(...Object.entries(spec).map(([key, value]) => resolveNode(`${path}.${key}`, value, loadValue).map((v) => [key, v]))).map((entries) => Object.fromEntries(entries));
106
+ if (spec instanceof EnvNode) return spec.validate(loadValue, path);
107
+ return collectValidationResults(...Object.entries(spec).map(([key, value]) => resolveNode(`${path}.${key}`, value, loadValue).map((v) => [key, v]))).map((entries) => Object.fromEntries(entries));
68
108
  }
69
109
 
70
110
  //#endregion
package/dist/env.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"env.mjs","names":[],"sources":["../src/env.ts"],"sourcesContent":["import type { EnvSpec, InferEnvSpec, Pretty } from './ast.js';\nimport { EnvNode, ScalarEnvNode } from './ast.js';\nimport type { ValidationResult } from './validation.js';\nimport { arrayIncludes, combineValidationResults } from './validation.js';\nimport { Either } from '@jeengbe/prelude';\n\nexport interface Env {\n string(key: string, defaultValue?: string): ScalarEnvNode<string>;\n number(key: string, defaultValue?: number): ScalarEnvNode<number>;\n boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean>;\n enum<const T extends string>(\n key: string,\n values: readonly T[],\n defaultValue?: T,\n ): ScalarEnvNode<T>;\n custom<T>(\n key: string,\n transform: (value: string) => ValidationResult<T>,\n defaultValue?: T,\n ): ScalarEnvNode<T>;\n array<T>(itemType: ScalarEnvNode<T>, defaultValue?: readonly T[]): EnvNode<readonly T[]>;\n discriminate<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n >(\n discriminatorKey: K,\n discriminatorValueType: ScalarEnvNode<V>,\n mapping: M,\n ): EnvNode<DiscriminatorResult<K, V, M>>;\n load<S extends EnvSpec>(spec: S): Pretty<InferEnvSpec<S>>;\n}\n\nexport const env: Env = {\n string(key, defaultValue) {\n return env.custom(key, (value) => Either.right(value), defaultValue);\n },\n\n number(key, defaultValue) {\n return env.custom(\n key,\n (value) => {\n if (/^-?\\d+(?:\\.\\d+)?$/.test(value)) {\n return Either.right(Number(value));\n }\n\n return Either.left(['invalid number']);\n },\n defaultValue,\n );\n },\n\n boolean(key, defaultValue) {\n return env.custom(\n key,\n (value) => {\n if (value.toLowerCase() === 'true') return Either.right(true);\n if (value.toLowerCase() === 'false') return Either.right(false);\n\n return Either.left([\"invalid boolean (must be 'true' or 'false')\"]);\n },\n defaultValue,\n );\n },\n\n enum(key, values, defaultValue) {\n return env.custom(\n key,\n (value) => {\n if (arrayIncludes(values, value)) return Either.right(value);\n\n return Either.left([\n `invalid enum value (must be one of: ${values.map((v) => `'${v}'`).join(', ')})`,\n ]);\n },\n defaultValue,\n );\n },\n\n custom(key, transform, defaultValue) {\n return new ScalarEnvNode(key, (value) => {\n if (value === undefined) {\n if (defaultValue !== undefined) return Either.right(defaultValue);\n return Either.left(['required']);\n }\n\n return transform(value);\n });\n },\n\n array<T>(itemType: ScalarEnvNode<T>, defaultValue?: readonly T[]): EnvNode<readonly T[]> {\n return new EnvNode((path, loadValue) => {\n const value = loadValue(itemType.key);\n if (value === undefined) {\n if (defaultValue !== undefined) return Either.right(defaultValue);\n return Either.left([`${itemType.key} (${path}): required`]);\n }\n if (value === '') return Either.right([]);\n\n return combineValidationResults(\n ...value\n .split(',')\n .map((v) => v.trim() || undefined)\n .map((v, i) =>\n itemType.validate(`${path}.${i}`, (k) => (k === itemType.key ? v : undefined)),\n ),\n );\n });\n },\n\n discriminate<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n >(\n discriminatorKey: K,\n discriminatorValueType: ScalarEnvNode<V>,\n mapping: M,\n ): EnvNode<DiscriminatorResult<K, V, M>> {\n return new EnvNode((path, loadValue) => {\n return discriminatorValueType.validate(path, loadValue).flatMap((discriminatorValue) => {\n return resolveNode<NonNullable<M[keyof M]> | {}>(\n path,\n mapping[discriminatorValue] ?? {},\n loadValue,\n ).map(\n (mappingValues) =>\n ({\n [discriminatorKey]: discriminatorValue,\n ...mappingValues,\n }) as DiscriminatorResult<K, V, M>,\n );\n });\n });\n },\n\n load(spec) {\n return resolveNode('$', spec, (key) => process.env[key]?.trim() || undefined).getOrElse(\n (errors) => {\n throw new Error(`Failed to load config: ${errors.join(', ')}`);\n },\n );\n },\n};\n\ntype DiscriminatorResult<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n> = Pretty<\n // This \"redundant\" condition is necessary to make sure that 'DiscriminatorResult' distributes over\n // the union type V\n V extends unknown ? Record<K, V> & InferEnvSpec<M[V] extends EnvSpec ? M[V] : {}> : never\n>;\n\nfunction resolveNode<S extends EnvSpec>(\n path: string,\n spec: S,\n loadValue: (key: string) => string | undefined,\n): ValidationResult<Pretty<InferEnvSpec<S>>> {\n if (spec instanceof EnvNode) {\n return (spec as EnvNode<Pretty<InferEnvSpec<S>>>).validate(path, loadValue);\n }\n\n return combineValidationResults(\n ...Object.entries(spec).map(([key, value]) =>\n resolveNode(`${path}.${key}`, value, loadValue).map((v) => [key, v] as const),\n ),\n ).map((entries) => Object.fromEntries(entries) as Pretty<InferEnvSpec<S>>);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"env.mjs","names":[],"sources":["../src/env.ts"],"sourcesContent":["import type { EnvSpec, ParseEnv, Pretty, ScalarValidationResultOrEither } from './ast.js';\nimport { EnvNode, ScalarEnvNode } from './ast.js';\nimport { ValidationResult } from './validation.js';\nimport { arrayIncludes, collectValidationResults } from './validation.js';\nimport { Either } from '@jeengbe/prelude';\n\n/**\n * Type-safe configuration loader.\n *\n * @example\n *\n * ```ts\n * import { env } from '@jeengbe/config';\n *\n * const config = env.load({\n * port: env.number('PORT', 3000),\n * debug: env.boolean('DEBUG', false),\n * driver: env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {\n * memory: {},\n * redis: { url: env.string('REDIS_URL') },\n * }),\n * });\n *\n * // -> {\n * // port: number;\n * // debug: boolean;\n * // driver: { driver: 'memory' } | { driver: 'redis'; url: string };\n * // }\n * ```\n *\n * Variables are read from `process.env` and trimmed before validation. Only a fully unset variable\n * falls back to a default value or fails as required - an explicitly empty value is passed through to\n * validation like any other input.\n */\nexport interface Env {\n /**\n * Reads a string environment variable.\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.string('API_KEY'));\n * // ^? string\n *\n * // API_KEY=abc-test -> 'abc-test'\n * // API_KEY=123 -> '123'\n * // API_KEY= -> ''\n * ```\n */\n string(key: string, defaultValue?: string): ScalarEnvNode<string>;\n\n /**\n * Reads a numeric environment variable. Must match `/^-?\\d+(?:\\.\\d+)?$/`, i.e. `[-]digits[.digits]`.\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.number('PORT'));\n * // ^? number\n *\n * // PORT=3000 -> 3000\n * // PORT=-1 -> -1\n * // PORT=abc -> error\n * ```\n */\n number(key: string, defaultValue?: number): ScalarEnvNode<number>;\n\n /**\n * Reads a boolean environment variable (must be 'true' or 'false').\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.boolean('DEBUG'));\n * // ^? boolean\n *\n * // DEBUG=true -> true\n * // DEBUG=false -> false\n * // DEBUG=abc -> error\n * ```\n */\n boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean>;\n\n /**\n * Reads an environment variable constrained to one of the provided values.\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.enum('DRIVER', ['memory', 'redis']));\n * // ^? 'memory' | 'redis'\n *\n * // DRIVER=memory -> 'memory'\n * // DRIVER=abc -> error\n * ```\n */\n enum<const T extends string>(\n key: string,\n values: readonly T[],\n defaultValue?: T,\n ): ScalarEnvNode<T>;\n\n /**\n * Reads a comma-separated list environment variable, validating each item against the provided item type.\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.array(env.string('API_KEYS')));\n * // ^? readonly string[]\n *\n * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']\n * // API_KEYS= -> []\n * // API_KEYS=abc,,ghi -> error\n * ```\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.array(env.string('API_KEYS'), ['default1', 'default2']));\n * // ^? readonly string[]\n *\n * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']\n * // API_KEYS= -> []\n * // (not set) -> ['default1', 'default2']\n * // API_KEYS=abc,,ghi -> error\n * ```\n *\n * @example\n *\n * ```ts\n * const res = env.load(env.array(env.string('API_KEYS').optional()));\n * // ^? readonly (string | undefined)[]\n *\n * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']\n * // API_KEYS= -> []\n * // API_KEYS=abc,,ghi -> ['abc', undefined, 'ghi']\n * ```\n */\n array<const T>(\n itemType: ScalarEnvNode<T>,\n defaultValue?: readonly T[],\n ): ScalarEnvNode<readonly T[]>;\n\n /**\n * Reads an environment variable, validating and transforming it with the provided function.\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? string\n * env.scalar('TAG', (value, path) =>\n * value.length === 3\n * ? ValidationResult.success({ value, defaulted: [] })\n * : ValidationResult.fail({\n * errors: [{ path, key: 'TAG', message: 'must be 3 characters long', value }],\n * }),\n * ),\n * );\n *\n * // TAG=abc -> 'abc'\n * // TAG=abcd -> error\n * ```\n */\n scalar<const T>(\n key: string,\n transform: (value: string, path: string) => ScalarValidationResultOrEither<T>,\n defaultValue?: T,\n ): ScalarEnvNode<T>;\n\n /**\n * Reads a discriminator environment variable and resolves the nested schema mapped to its value.\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? { driver: 'memory'; } | { driver: 'redis'; url: string; }\n * env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {\n * memory: {},\n * redis: { url: env.string('REDIS_URL') },\n * }),\n * );\n *\n * // DRIVER=memory -> { driver: 'memory' }\n * // DRIVER=redis, REDIS_URL=redis://localhost -> { driver: 'redis', url: 'redis://localhost' }\n * // DRIVER=redis, REDIS_URL= -> { driver: 'redis', url: '' }\n * // DRIVER=abc -> error\n * ```\n */\n discriminate<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n >(\n discriminatorKey: K,\n discriminatorValueType: ScalarEnvNode<V>,\n mapping: M,\n ): EnvNode<DiscriminatorResult<K, V, M>>;\n\n /**\n * Validates and loads the provided schema from `process.env`. Returns a `ValidationResult` that contains either the parsed values or a list of validation errors.\n *\n * @example\n *\n * ```ts\n * const result = env.parse({\n * port: env.number('PORT'),\n * debug: env.boolean('DEBUG'),\n * });\n *\n * if (result.getLeft()) {\n * console.error('Validation failed:', result.getLeft());\n * } else {\n * const config = result.get()!;\n * // -> { port: number; debug: boolean; }\n * }\n * ```\n */\n parse<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): ValidationResult<Pretty<ParseEnv<S>>>;\n\n /**\n * Validates and loads the provided schema from `process.env`. Throws if any required variables are missing or invalid.\n *\n * @example\n *\n * ```ts\n * const config = env.load({\n * port: env.number('PORT', 3000),\n * debug: env.boolean('DEBUG', false),\n * }, console.log);\n *\n * // -> {\n * // port: number;\n * // debug: boolean;\n * // }\n * ```\n */\n load<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): Pretty<ParseEnv<S>>;\n}\n\nexport const env: Env = {\n string(key: string, defaultValue?: string): ScalarEnvNode<string> {\n return env.scalar(\n key,\n (value): ValidationResult<string> => ValidationResult.success({ value, defaulted: [] }),\n defaultValue,\n );\n },\n\n number(key: string, defaultValue?: number): ScalarEnvNode<number> {\n return env.scalar(\n key,\n (value, path): ValidationResult<number> => {\n if (/^-?\\d+(?:\\.\\d+)?$/.test(value)) {\n return ValidationResult.success({ value: Number(value), defaulted: [] });\n }\n\n return ValidationResult.fail({\n errors: [\n {\n path,\n key,\n message: 'invalid number',\n value,\n },\n ],\n });\n },\n defaultValue,\n );\n },\n\n boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean> {\n return env.scalar(\n key,\n (value, path): ValidationResult<boolean> => {\n if (value.toLowerCase() === 'true') {\n return ValidationResult.success({ value: true, defaulted: [] });\n }\n\n if (value.toLowerCase() === 'false') {\n return ValidationResult.success({ value: false, defaulted: [] });\n }\n\n return ValidationResult.fail({\n errors: [\n {\n path,\n key,\n message: 'invalid boolean',\n formatHint: \"must be 'true' or 'false'\",\n value,\n },\n ],\n });\n },\n defaultValue,\n );\n },\n\n enum<const T extends string>(\n key: string,\n values: readonly T[],\n defaultValue?: T,\n ): ScalarEnvNode<T> {\n return env.scalar(\n key,\n (value, path): ValidationResult<T> => {\n if (arrayIncludes(values, value)) return ValidationResult.success({ value, defaulted: [] });\n\n return ValidationResult.fail({\n errors: [\n {\n path,\n key,\n message: 'invalid enum value',\n formatHint: `must be one of: ${values.map((v) => `'${v}'`).join(', ')}`,\n value,\n },\n ],\n });\n },\n defaultValue,\n );\n },\n\n array<const T>(\n itemType: ScalarEnvNode<T>,\n defaultValue?: readonly T[],\n ): ScalarEnvNode<readonly T[]> {\n return env.scalar(\n itemType.key,\n (value, path): ValidationResult<readonly T[]> => {\n // Special case because ''.split(',') returns [''] instead of [].\n if (value === '') return ValidationResult.success({ value: [], defaulted: [] });\n\n return collectValidationResults(\n ...value\n .split(',')\n .map((v) => v.trim() || undefined)\n .map((v, i) =>\n itemType.validate(\n (k) =>\n // itemType always reads its own key, so this is never false\n k === itemType.key ? v : /* v8 ignore next */ undefined,\n `${path}.${i}`,\n ),\n ),\n );\n },\n defaultValue,\n );\n },\n\n scalar<T>(\n key: string,\n transform: (value: string, path: string) => ScalarValidationResultOrEither<T>,\n defaultValue?: T,\n ): ScalarEnvNode<T> {\n return new ScalarEnvNode(key, (value, path): ScalarValidationResultOrEither<T> => {\n if (value === undefined) {\n if (defaultValue !== undefined) {\n return ValidationResult.success({\n value: defaultValue,\n defaulted: [\n {\n path,\n key,\n defaultValue,\n },\n ],\n });\n }\n\n return Either.left('required');\n }\n\n return transform(value, path);\n });\n },\n\n discriminate<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n >(\n discriminatorKey: K,\n discriminatorValueType: ScalarEnvNode<V>,\n mapping: M,\n ): EnvNode<DiscriminatorResult<K, V, M>> {\n return new EnvNode((loadValue, path): ValidationResult<DiscriminatorResult<K, V, M>> => {\n return discriminatorValueType\n .validate(loadValue, `${path}.${discriminatorKey}`)\n .flatMap((discriminatorValue) => {\n return resolveNode<NonNullable<M[keyof M]> | {}>(\n path,\n mapping[discriminatorValue] ?? {},\n loadValue,\n ).map(\n (mappingValues) =>\n ({\n [discriminatorKey]: discriminatorValue,\n ...mappingValues,\n }) as DiscriminatorResult<K, V, M>,\n );\n });\n });\n },\n\n parse<S extends EnvSpec>(spec: S, env = process.env): ValidationResult<Pretty<ParseEnv<S>>> {\n return resolveNode('$', spec, (key) => env[key]?.trim());\n },\n\n load<S extends EnvSpec>(spec: S, env = process.env): Pretty<ParseEnv<S>> {\n return this.parse(spec, env).fold(\n (failure) => {\n throw new Error(\n `Environment validation failed:\\n${failure.errors\n .map(\n (e) =>\n // oxlint-disable-next-line typescript/no-base-to-string\n ` ${e.path} (${e.key}): ${e.message} (${[e.formatHint, `got: '${String(e.value ?? '<not provided>')}'`].filter((x) => x).join('; ')})`,\n )\n .join('\\n')}`,\n );\n },\n ({ value }) => value,\n );\n },\n};\n\ntype DiscriminatorResult<\n K extends string,\n V extends string,\n M extends Partial<Record<V, Record<string, EnvSpec>>>,\n> = Pretty<\n // This \"redundant\" condition is necessary to make sure that 'DiscriminatorResult' distributes over\n // the union type V\n V extends unknown ? Record<K, V> & ParseEnv<M[V] extends EnvSpec ? M[V] : {}> : never\n>;\n\nfunction resolveNode<S extends EnvSpec>(\n path: string,\n spec: S,\n loadValue: (key: string) => string | undefined,\n): ValidationResult<Pretty<ParseEnv<S>>> {\n if (spec instanceof EnvNode) {\n return (spec as EnvNode<Pretty<ParseEnv<S>>>).validate(loadValue, path);\n }\n\n return collectValidationResults(\n ...Object.entries(spec).map(([key, value]) =>\n resolveNode(`${path}.${key}`, value, loadValue).map((v) => [key, v] as const),\n ),\n ).map((entries) => Object.fromEntries(entries) as Pretty<ParseEnv<S>>);\n}\n"],"mappings":"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"}
@@ -1,11 +1,22 @@
1
- import { EnvNode, EnvSpec, InferEnvSpec } from "./ast.mjs";
1
+ import { EnvNode, EnvSpec, ParseEnv } from "./ast.mjs";
2
2
  //#region src/if-enabled.d.ts
3
3
  type IfEnabled<T> = ({
4
4
  enabled: true;
5
5
  } & Omit<T, 'enabled'>) | {
6
6
  enabled: false;
7
7
  };
8
- declare function ifEnabled<T extends Record<string, EnvSpec>>(envVar: string, config: T, defaultEnabled?: boolean): EnvNode<IfEnabled<InferEnvSpec<T>>>;
8
+ /**
9
+ * Wraps a config schema behind an `enabled` boolean flag read from `envVar`. When disabled, none of the
10
+ * nested schema's environment variables are validated or required.
11
+ *
12
+ * @example
13
+ *
14
+ * ```ts
15
+ * ifEnabled('FEATURE_X', { apiKey: env.string('FEATURE_X_API_KEY') });
16
+ * // -> { enabled: true; apiKey: string } | { enabled: false }
17
+ * ```
18
+ */
19
+ declare function ifEnabled<T extends Record<string, EnvSpec>>(envVar: string, config: T, defaultEnabled?: boolean): EnvNode<IfEnabled<ParseEnv<T>>>;
9
20
  //#endregion
10
21
  export { ifEnabled };
11
22
  //# sourceMappingURL=if-enabled.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"if-enabled.d.mts","names":[],"sources":["../src/if-enabled.ts"],"mappings":";;KAKK,UAAU;EAET;IACE,KAAK;EACP;;iBAEU,UAAU,UAAU,eAAe,UACjD,gBACA,QAAQ,GACR,2BACC,QAAQ,UAAU,aAAa"}
1
+ {"version":3,"file":"if-enabled.d.mts","names":[],"sources":["../src/if-enabled.ts"],"mappings":";;KAIK,UAAU;EAET;IACE,KAAK;EACP;;;;;;;;;;;;;iBAaU,UAAU,UAAU,eAAe,UACjD,gBACA,QAAQ,GACR,2BACC,QAAQ,UAAU,SAAS"}
@@ -2,6 +2,17 @@ import { env } from "./env.mjs";
2
2
  import { Either } from "@jeengbe/prelude";
3
3
 
4
4
  //#region src/if-enabled.ts
5
+ /**
6
+ * Wraps a config schema behind an `enabled` boolean flag read from `envVar`. When disabled, none of the
7
+ * nested schema's environment variables are validated or required.
8
+ *
9
+ * @example
10
+ *
11
+ * ```ts
12
+ * ifEnabled('FEATURE_X', { apiKey: env.string('FEATURE_X_API_KEY') });
13
+ * // -> { enabled: true; apiKey: string } | { enabled: false }
14
+ * ```
15
+ */
5
16
  function ifEnabled(envVar, config, defaultEnabled = false) {
6
17
  return env.discriminate("enabled", env.boolean(envVar, defaultEnabled).transform((v) => Either.right(v ? "enabled" : "disabled")), { enabled: config }).transform((value) => {
7
18
  if (value.enabled === "enabled") return Either.right({
@@ -1 +1 @@
1
- {"version":3,"file":"if-enabled.mjs","names":[],"sources":["../src/if-enabled.ts"],"sourcesContent":["import type { EnvNode, EnvSpec, InferEnvSpec } from './ast.js';\nimport { env } from './env.js';\nimport type { ValidationResult } from './validation.js';\nimport { Either } from '@jeengbe/prelude';\n\ntype IfEnabled<T> =\n | ({\n enabled: true;\n } & Omit<T, 'enabled'>)\n | { enabled: false };\n\nexport function ifEnabled<T extends Record<string, EnvSpec>>(\n envVar: string,\n config: T,\n defaultEnabled = false,\n): EnvNode<IfEnabled<InferEnvSpec<T>>> {\n // Since discriminate only works with string values, we need to bridge 'true' -> 'enabled' -> true\n return env\n .discriminate(\n 'enabled',\n env\n .boolean(envVar, defaultEnabled)\n .transform(\n (v): ValidationResult<'enabled' | 'disabled'> => Either.right(v ? 'enabled' : 'disabled'),\n ),\n {\n enabled: config,\n },\n )\n .transform((value): ValidationResult<IfEnabled<InferEnvSpec<T>>> => {\n if (value.enabled === 'enabled') {\n return Either.right({\n ...value,\n enabled: true,\n });\n }\n\n return Either.right({\n enabled: false,\n });\n });\n}\n"],"mappings":";;;;AAWA,SAAgB,UACd,QACA,QACA,iBAAiB,OACoB;CAErC,OAAO,IACJ,aACC,WACA,IACG,QAAQ,QAAQ,cAAc,CAAC,CAC/B,WACE,MAAgD,OAAO,MAAM,IAAI,YAAY,UAAU,CAC1F,GACF,EACE,SAAS,OACX,CACF,CAAC,CACA,WAAW,UAAwD;EAClE,IAAI,MAAM,YAAY,WACpB,OAAO,OAAO,MAAM;GAClB,GAAG;GACH,SAAS;EACX,CAAC;EAGH,OAAO,OAAO,MAAM,EAClB,SAAS,MACX,CAAC;CACH,CAAC;AACL"}
1
+ {"version":3,"file":"if-enabled.mjs","names":[],"sources":["../src/if-enabled.ts"],"sourcesContent":["import type { EnvNode, EnvSpec, ParseEnv } from './ast.js';\nimport { env } from './env.js';\nimport { Either } from '@jeengbe/prelude';\n\ntype IfEnabled<T> =\n | ({\n enabled: true;\n } & Omit<T, 'enabled'>)\n | { enabled: false };\n\n/**\n * Wraps a config schema behind an `enabled` boolean flag read from `envVar`. When disabled, none of the\n * nested schema's environment variables are validated or required.\n *\n * @example\n *\n * ```ts\n * ifEnabled('FEATURE_X', { apiKey: env.string('FEATURE_X_API_KEY') });\n * // -> { enabled: true; apiKey: string } | { enabled: false }\n * ```\n */\nexport function ifEnabled<T extends Record<string, EnvSpec>>(\n envVar: string,\n config: T,\n defaultEnabled = false,\n): EnvNode<IfEnabled<ParseEnv<T>>> {\n // Since discriminate only works with string values, we need to bridge 'true' -> 'enabled' -> true\n return env\n .discriminate(\n 'enabled',\n env\n .boolean(envVar, defaultEnabled)\n .transform<'enabled' | 'disabled'>((v) => Either.right(v ? 'enabled' : 'disabled')),\n {\n enabled: config,\n },\n )\n .transform((value): Either<never, IfEnabled<ParseEnv<T>>> => {\n if (value.enabled === 'enabled') {\n return Either.right({\n ...value,\n enabled: true,\n });\n }\n\n return Either.right({\n enabled: false,\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAqBA,SAAgB,UACd,QACA,QACA,iBAAiB,OACgB;CAEjC,OAAO,IACJ,aACC,WACA,IACG,QAAQ,QAAQ,cAAc,CAAC,CAC/B,WAAmC,MAAM,OAAO,MAAM,IAAI,YAAY,UAAU,CAAC,GACpF,EACE,SAAS,OACX,CACF,CAAC,CACA,WAAW,UAAiD;EAC3D,IAAI,MAAM,YAAY,WACpB,OAAO,OAAO,MAAM;GAClB,GAAG;GACH,SAAS;EACX,CAAC;EAGH,OAAO,OAAO,MAAM,EAClB,SAAS,MACX,CAAC;CACH,CAAC;AACL"}
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { EnvNode, EnvSpec, InferEnvSpec } from "./ast.mjs";
1
+ import { ValidationDefaulted, ValidationError, ValidationFailure, ValidationResult, ValidationSuccess, collectValidationResults } from "./validation.mjs";
2
+ import { EnvNode, EnvSpec, ParseEnv, ScalarEnvNode } from "./ast.mjs";
2
3
  import { env } from "./env.mjs";
3
4
  import { ifEnabled } from "./if-enabled.mjs";
4
- export { type EnvNode, type EnvSpec, type InferEnvSpec, env, ifEnabled };
5
+ export { type EnvNode, type EnvSpec, type ParseEnv, type ScalarEnvNode, type ValidationDefaulted, type ValidationError, type ValidationFailure, ValidationResult, type ValidationSuccess, collectValidationResults, env, ifEnabled };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
+ import { ValidationResult, collectValidationResults } from "./validation.mjs";
1
2
  import { env } from "./env.mjs";
2
3
  import { ifEnabled } from "./if-enabled.mjs";
3
4
 
4
- export { env, ifEnabled };
5
+ export { ValidationResult, collectValidationResults, env, ifEnabled };
@@ -1,9 +1,106 @@
1
- import { Either } from "@jeengbe/prelude";
1
+ import { Maybe } from "@jeengbe/prelude";
2
2
  //#region src/validation.d.ts
3
- type ValidationResult<T> = Either<readonly string[], T>;
4
- type CombineValidationResult<T extends readonly ValidationResult<unknown>[]> = ValidationResult<{ [K in keyof T]: T[K] extends ValidationResult<infer U> ? U : never; }>;
5
- declare function combineValidationResults<const U extends readonly ValidationResult<unknown>[]>(...results: U): CombineValidationResult<U>;
6
- declare function arrayIncludes<const T extends string>(arr: readonly T[], val: unknown): val is T;
3
+ /**
4
+ * The result of validating a config value: a Left of accumulated structured errors, or a Right of the
5
+ * validated value together with a log of every default value that was substituted along the way.
6
+ *
7
+ * `map` and `flatMap` behave like a regular value monad, except that `flatMap` also concatenates the
8
+ * defaulted-key log of both sides instead of discarding either - so chaining validations never loses
9
+ * track of which defaults were applied upstream.
10
+ */
11
+ declare class ValidationResult<T> {
12
+ private readonly result;
13
+ private constructor();
14
+ /**
15
+ * Creates a successful ValidationResult with the given value and log of defaulted keys.
16
+ */
17
+ static success<T>(success: ValidationSuccess<T>): ValidationResult<T>;
18
+ /**
19
+ * Creates a failed ValidationResult with the given errors.
20
+ */
21
+ static fail(failure: ValidationFailure): ValidationResult<never>;
22
+ /**
23
+ * Maps the value of this ValidationResult if it succeeded, preserving its defaulted-key log. Performs
24
+ * no operation if this is a failure.
25
+ */
26
+ map<U>(f: (value: T) => U): ValidationResult<U>;
27
+ /**
28
+ * Flat maps the value of this ValidationResult if it succeeded, concatenating the defaulted-key log of
29
+ * this result with that of the one returned by `f`. Performs no operation if this is a failure.
30
+ */
31
+ flatMap<U>(f: (value: T) => ValidationResult<U>): ValidationResult<U>;
32
+ /**
33
+ * Applies the provided functions to this ValidationResult, depending on whether it failed or succeeded,
34
+ * and returns the result.
35
+ */
36
+ fold<R1, R2>(onFailure: (failure: ValidationFailure) => R1, onSuccess: (success: ValidationSuccess<T>) => R2): R1 | R2;
37
+ getLeft(): Maybe<ValidationFailure>;
38
+ get(): Maybe<T>;
39
+ }
40
+ interface ValidationFailure {
41
+ errors: readonly ValidationError[];
42
+ }
43
+ /**
44
+ * A single structured validation error, pinpointing the key and path it occurred at.
45
+ */
46
+ interface ValidationError {
47
+ path: string;
48
+ key: string;
49
+ message: string;
50
+ formatHint?: string;
51
+ value: unknown;
52
+ }
53
+ interface ValidationSuccess<T> {
54
+ defaulted: readonly ValidationDefaulted[];
55
+ value: T;
56
+ }
57
+ /**
58
+ * Records that a default value was substituted in place of a missing environment variable.
59
+ */
60
+ interface ValidationDefaulted {
61
+ path: string;
62
+ key: string;
63
+ defaultValue: unknown;
64
+ }
65
+ /**
66
+ * Combines multiple ValidationResults into one, preserving the tuple's value types. Succeeds with all
67
+ * values and the concatenation of every branch's defaulted-key log if every result succeeded, or fails
68
+ * with all accumulated errors otherwise.
69
+ *
70
+ * @example
71
+ *
72
+ * ```ts
73
+ * const result1: ValidationResult<number> = ValidationResult.fail({
74
+ * errors: [{ path: '$.a', key: 'A', message: 'error1', value: undefined }],
75
+ * });
76
+ * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });
77
+ * const result3: ValidationResult<boolean> = ValidationResult.fail({
78
+ * errors: [{ path: '$.c', key: 'C', message: 'error3', value: undefined }],
79
+ * });
80
+ *
81
+ * const combinedResult = collectValidationResults(result1, result2, result3);
82
+ *
83
+ * console.log(combinedResult); // Left with both `error1` and `error3`
84
+ * ```
85
+ *
86
+ * @example
87
+ *
88
+ * ```ts
89
+ * const result1: ValidationResult<number> = ValidationResult.success({ value: 42, defaulted: [] });
90
+ * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });
91
+ * const result3: ValidationResult<boolean> = ValidationResult.success({ value: true, defaulted: [] });
92
+ *
93
+ * const combinedResult = collectValidationResults(result1, result2, result3);
94
+ *
95
+ * console.log(combinedResult); // Right([42, 'value2', true])
96
+ * ```
97
+ */
98
+ declare function collectValidationResults<const U extends readonly ValidationResult<unknown>[]>(...results: U): CollectValidationResult<U>;
99
+ type CollectValidationResult<T extends readonly ValidationResult<unknown>[]> = ValidationResult<{ [K in keyof T]: T[K] extends ValidationResult<infer U> ? U : never; }>;
100
+ /**
101
+ * Type guard checking whether val is one of the values in arr.
102
+ */
103
+ declare function arrayIncludes<const T extends string | number | boolean | null | undefined>(arr: readonly T[], val: unknown): val is T;
7
104
  //#endregion
8
- export { ValidationResult, arrayIncludes, combineValidationResults };
105
+ export { ValidationDefaulted, ValidationError, ValidationFailure, ValidationResult, ValidationSuccess, arrayIncludes, collectValidationResults };
9
106
  //# sourceMappingURL=validation.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validation.d.mts","names":[],"sources":["../src/validation.ts"],"mappings":";;KAEY,iBAAiB,KAAK,0BAA0B;KAEvD,wBAAwB,mBAAmB,+BAA+B,oBAC5E,WAAW,IAAI,EAAE,WAAW,uBAAuB,KAAK;iBAG3C,+BAA+B,mBAAmB,gCAC7D,SAAS,IACX,wBAAwB;iBAQX,oBAAoB,kBAAkB,cAAc,KAAK,eAAe,OAAO"}
1
+ {"version":3,"file":"validation.d.mts","names":[],"sources":["../src/validation.ts"],"mappings":";;;;;;;;;;cAUa,iBAAiB;mBACS;UAA9B;;;;SAKA,QAAQ,GAAG,SAAS,kBAAkB,KAAK,iBAAiB;;;;SAO5D,KAAK,SAAS,oBAAoB;;;;;EAQzC,IAAI,GAAG,IAAI,OAAO,MAAM,IAAI,iBAAiB;;;;;EAU7C,QAAQ,GAAG,IAAI,OAAO,MAAM,iBAAiB,KAAK,iBAAiB;;;;;EAenE,KAAK,IAAI,IACP,YAAY,SAAS,sBAAsB,IAC3C,YAAY,SAAS,kBAAkB,OAAO,KAC7C,KAAK;EAIR,WAAW,MAAM;EAIjB,OAAO,MAAM;;UAKE;EACf,iBAAiB;;;;;UAMF;EACf;EACA;EACA;EACA;EACA;;UAGe,kBAAkB;EACjC,oBAAoB;EACpB,OAAO;;;;;UAMQ;EACf;EACA;EACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAoCc,+BAA+B,mBAAmB,gCAC7D,SAAS,IACX,wBAAwB;KAsBtB,wBAAwB,mBAAmB,+BAA+B,oBAC5E,WAAW,IAAI,EAAE,WAAW,uBAAuB,KAAK;;;;iBAM3C,oBAAoB,wDAClC,cAAc,KACd,eACC,OAAO"}
@@ -1,14 +1,118 @@
1
- import { Either } from "@jeengbe/prelude";
1
+ import { Either, mapMaybe } from "@jeengbe/prelude";
2
2
 
3
3
  //#region src/validation.ts
4
- function combineValidationResults(...results) {
5
- const errors = results.filter((r) => r.isLeft()).flatMap((r) => r.getLeft());
6
- return errors.length ? Either.left(errors) : Either.right(results.map((r) => r.get()));
4
+ /**
5
+ * The result of validating a config value: a Left of accumulated structured errors, or a Right of the
6
+ * validated value together with a log of every default value that was substituted along the way.
7
+ *
8
+ * `map` and `flatMap` behave like a regular value monad, except that `flatMap` also concatenates the
9
+ * defaulted-key log of both sides instead of discarding either - so chaining validations never loses
10
+ * track of which defaults were applied upstream.
11
+ */
12
+ var ValidationResult = class ValidationResult {
13
+ result;
14
+ constructor(result) {
15
+ this.result = result;
16
+ }
17
+ /**
18
+ * Creates a successful ValidationResult with the given value and log of defaulted keys.
19
+ */
20
+ static success(success) {
21
+ return new ValidationResult(Either.right(success));
22
+ }
23
+ /**
24
+ * Creates a failed ValidationResult with the given errors.
25
+ */
26
+ static fail(failure) {
27
+ return new ValidationResult(Either.left(failure));
28
+ }
29
+ /**
30
+ * Maps the value of this ValidationResult if it succeeded, preserving its defaulted-key log. Performs
31
+ * no operation if this is a failure.
32
+ */
33
+ map(f) {
34
+ return new ValidationResult(this.result.map(({ defaulted, value }) => ({
35
+ defaulted,
36
+ value: f(value)
37
+ })));
38
+ }
39
+ /**
40
+ * Flat maps the value of this ValidationResult if it succeeded, concatenating the defaulted-key log of
41
+ * this result with that of the one returned by `f`. Performs no operation if this is a failure.
42
+ */
43
+ flatMap(f) {
44
+ return new ValidationResult(this.result.flatMap(({ defaulted, value }) => f(value).result.map(({ defaulted: newDefaulted, value: newValue }) => ({
45
+ defaulted: [...defaulted, ...newDefaulted],
46
+ value: newValue
47
+ }))));
48
+ }
49
+ /**
50
+ * Applies the provided functions to this ValidationResult, depending on whether it failed or succeeded,
51
+ * and returns the result.
52
+ */
53
+ fold(onFailure, onSuccess) {
54
+ return this.result.fold(onFailure, onSuccess);
55
+ }
56
+ getLeft() {
57
+ return this.result.getLeft();
58
+ }
59
+ get() {
60
+ return mapMaybe(this.result.get(), ({ value }) => value);
61
+ }
62
+ };
63
+ /**
64
+ * Combines multiple ValidationResults into one, preserving the tuple's value types. Succeeds with all
65
+ * values and the concatenation of every branch's defaulted-key log if every result succeeded, or fails
66
+ * with all accumulated errors otherwise.
67
+ *
68
+ * @example
69
+ *
70
+ * ```ts
71
+ * const result1: ValidationResult<number> = ValidationResult.fail({
72
+ * errors: [{ path: '$.a', key: 'A', message: 'error1', value: undefined }],
73
+ * });
74
+ * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });
75
+ * const result3: ValidationResult<boolean> = ValidationResult.fail({
76
+ * errors: [{ path: '$.c', key: 'C', message: 'error3', value: undefined }],
77
+ * });
78
+ *
79
+ * const combinedResult = collectValidationResults(result1, result2, result3);
80
+ *
81
+ * console.log(combinedResult); // Left with both `error1` and `error3`
82
+ * ```
83
+ *
84
+ * @example
85
+ *
86
+ * ```ts
87
+ * const result1: ValidationResult<number> = ValidationResult.success({ value: 42, defaulted: [] });
88
+ * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });
89
+ * const result3: ValidationResult<boolean> = ValidationResult.success({ value: true, defaulted: [] });
90
+ *
91
+ * const combinedResult = collectValidationResults(result1, result2, result3);
92
+ *
93
+ * console.log(combinedResult); // Right([42, 'value2', true])
94
+ * ```
95
+ */
96
+ function collectValidationResults(...results) {
97
+ const errors = [];
98
+ const defaulted = [];
99
+ const values = [];
100
+ for (const result of results) result.fold((failure) => errors.push(...failure.errors), (success) => {
101
+ defaulted.push(...success.defaulted);
102
+ values.push(success.value);
103
+ });
104
+ return errors.length ? ValidationResult.fail({ errors }) : ValidationResult.success({
105
+ defaulted,
106
+ value: values
107
+ });
7
108
  }
109
+ /**
110
+ * Type guard checking whether val is one of the values in arr.
111
+ */
8
112
  function arrayIncludes(arr, val) {
9
113
  return arr.includes(val);
10
114
  }
11
115
 
12
116
  //#endregion
13
- export { arrayIncludes, combineValidationResults };
117
+ export { ValidationResult, arrayIncludes, collectValidationResults };
14
118
  //# sourceMappingURL=validation.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"validation.mjs","names":[],"sources":["../src/validation.ts"],"sourcesContent":["import { Either } from '@jeengbe/prelude';\n\nexport type ValidationResult<T> = Either<readonly string[], T>;\n\ntype CombineValidationResult<T extends readonly ValidationResult<unknown>[]> = ValidationResult<{\n [K in keyof T]: T[K] extends ValidationResult<infer U> ? U : never;\n}>;\n\nexport function combineValidationResults<const U extends readonly ValidationResult<unknown>[]>(\n ...results: U\n): CombineValidationResult<U> {\n const errors = results.filter((r) => r.isLeft()).flatMap((r) => r.getLeft());\n\n return errors.length\n ? Either.left(errors)\n : (Either.right(results.map((r) => r.get())) as CombineValidationResult<U>);\n}\n\nexport function arrayIncludes<const T extends string>(arr: readonly T[], val: unknown): val is T {\n return arr.includes(val as T);\n}\n"],"mappings":";;;AAQA,SAAgB,yBACd,GAAG,SACyB;CAC5B,MAAM,SAAS,QAAQ,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS,MAAM,EAAE,QAAQ,CAAC;CAE3E,OAAO,OAAO,SACV,OAAO,KAAK,MAAM,IACjB,OAAO,MAAM,QAAQ,KAAK,MAAM,EAAE,IAAI,CAAC,CAAC;AAC/C;AAEA,SAAgB,cAAsC,KAAmB,KAAwB;CAC/F,OAAO,IAAI,SAAS,GAAQ;AAC9B"}
1
+ {"version":3,"file":"validation.mjs","names":[],"sources":["../src/validation.ts"],"sourcesContent":["import { Either, mapMaybe, Maybe } from '@jeengbe/prelude';\n\n/**\n * The result of validating a config value: a Left of accumulated structured errors, or a Right of the\n * validated value together with a log of every default value that was substituted along the way.\n *\n * `map` and `flatMap` behave like a regular value monad, except that `flatMap` also concatenates the\n * defaulted-key log of both sides instead of discarding either - so chaining validations never loses\n * track of which defaults were applied upstream.\n */\nexport class ValidationResult<T> {\n private constructor(private readonly result: Either<ValidationFailure, ValidationSuccess<T>>) {}\n\n /**\n * Creates a successful ValidationResult with the given value and log of defaulted keys.\n */\n static success<T>(success: ValidationSuccess<T>): ValidationResult<T> {\n return new ValidationResult(Either.right(success));\n }\n\n /**\n * Creates a failed ValidationResult with the given errors.\n */\n static fail(failure: ValidationFailure): ValidationResult<never> {\n return new ValidationResult(Either.left(failure));\n }\n\n /**\n * Maps the value of this ValidationResult if it succeeded, preserving its defaulted-key log. Performs\n * no operation if this is a failure.\n */\n map<U>(f: (value: T) => U): ValidationResult<U> {\n return new ValidationResult(\n this.result.map(({ defaulted, value }) => ({ defaulted, value: f(value) })),\n );\n }\n\n /**\n * Flat maps the value of this ValidationResult if it succeeded, concatenating the defaulted-key log of\n * this result with that of the one returned by `f`. Performs no operation if this is a failure.\n */\n flatMap<U>(f: (value: T) => ValidationResult<U>): ValidationResult<U> {\n return new ValidationResult(\n this.result.flatMap(({ defaulted, value }) =>\n f(value).result.map(({ defaulted: newDefaulted, value: newValue }) => ({\n defaulted: [...defaulted, ...newDefaulted],\n value: newValue,\n })),\n ),\n );\n }\n\n /**\n * Applies the provided functions to this ValidationResult, depending on whether it failed or succeeded,\n * and returns the result.\n */\n fold<R1, R2>(\n onFailure: (failure: ValidationFailure) => R1,\n onSuccess: (success: ValidationSuccess<T>) => R2,\n ): R1 | R2 {\n return this.result.fold(onFailure, onSuccess);\n }\n\n getLeft(): Maybe<ValidationFailure> {\n return this.result.getLeft();\n }\n\n get(): Maybe<T> {\n return mapMaybe(this.result.get(), ({ value }) => value);\n }\n}\n\nexport interface ValidationFailure {\n errors: readonly ValidationError[];\n}\n\n/**\n * A single structured validation error, pinpointing the key and path it occurred at.\n */\nexport interface ValidationError {\n path: string;\n key: string;\n message: string;\n formatHint?: string;\n value: unknown;\n}\n\nexport interface ValidationSuccess<T> {\n defaulted: readonly ValidationDefaulted[];\n value: T;\n}\n\n/**\n * Records that a default value was substituted in place of a missing environment variable.\n */\nexport interface ValidationDefaulted {\n path: string;\n key: string;\n defaultValue: unknown;\n}\n\n/**\n * Combines multiple ValidationResults into one, preserving the tuple's value types. Succeeds with all\n * values and the concatenation of every branch's defaulted-key log if every result succeeded, or fails\n * with all accumulated errors otherwise.\n *\n * @example\n *\n * ```ts\n * const result1: ValidationResult<number> = ValidationResult.fail({\n * errors: [{ path: '$.a', key: 'A', message: 'error1', value: undefined }],\n * });\n * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });\n * const result3: ValidationResult<boolean> = ValidationResult.fail({\n * errors: [{ path: '$.c', key: 'C', message: 'error3', value: undefined }],\n * });\n *\n * const combinedResult = collectValidationResults(result1, result2, result3);\n *\n * console.log(combinedResult); // Left with both `error1` and `error3`\n * ```\n *\n * @example\n *\n * ```ts\n * const result1: ValidationResult<number> = ValidationResult.success({ value: 42, defaulted: [] });\n * const result2: ValidationResult<string> = ValidationResult.success({ value: 'value2', defaulted: [] });\n * const result3: ValidationResult<boolean> = ValidationResult.success({ value: true, defaulted: [] });\n *\n * const combinedResult = collectValidationResults(result1, result2, result3);\n *\n * console.log(combinedResult); // Right([42, 'value2', true])\n * ```\n */\nexport function collectValidationResults<const U extends readonly ValidationResult<unknown>[]>(\n ...results: U\n): CollectValidationResult<U> {\n const errors: ValidationError[] = [];\n const defaulted: ValidationDefaulted[] = [];\n const values: unknown[] = [];\n\n for (const result of results) {\n result.fold(\n (failure) => errors.push(...failure.errors),\n (success) => {\n defaulted.push(...success.defaulted);\n values.push(success.value);\n },\n );\n }\n\n return (\n errors.length\n ? ValidationResult.fail({ errors })\n : ValidationResult.success({ defaulted, value: values })\n ) as CollectValidationResult<U>;\n}\n\ntype CollectValidationResult<T extends readonly ValidationResult<unknown>[]> = ValidationResult<{\n [K in keyof T]: T[K] extends ValidationResult<infer U> ? U : never;\n}>;\n\n/**\n * Type guard checking whether val is one of the values in arr.\n */\nexport function arrayIncludes<const T extends string | number | boolean | null | undefined>(\n arr: readonly T[],\n val: unknown,\n): val is T {\n return arr.includes(val as T);\n}\n"],"mappings":";;;;;;;;;;;AAUA,IAAa,mBAAb,MAAa,iBAAoB;CACM;CAArC,AAAQ,YAAY,AAAiB,QAAyD;EAAzD;CAA0D;;;;CAK/F,OAAO,QAAW,SAAoD;EACpE,OAAO,IAAI,iBAAiB,OAAO,MAAM,OAAO,CAAC;CACnD;;;;CAKA,OAAO,KAAK,SAAqD;EAC/D,OAAO,IAAI,iBAAiB,OAAO,KAAK,OAAO,CAAC;CAClD;;;;;CAMA,IAAO,GAAyC;EAC9C,OAAO,IAAI,iBACT,KAAK,OAAO,KAAK,EAAE,WAAW,aAAa;GAAE;GAAW,OAAO,EAAE,KAAK;EAAE,EAAE,CAC5E;CACF;;;;;CAMA,QAAW,GAA2D;EACpE,OAAO,IAAI,iBACT,KAAK,OAAO,SAAS,EAAE,WAAW,YAChC,EAAE,KAAK,CAAC,CAAC,OAAO,KAAK,EAAE,WAAW,cAAc,OAAO,gBAAgB;GACrE,WAAW,CAAC,GAAG,WAAW,GAAG,YAAY;GACzC,OAAO;EACT,EAAE,CACJ,CACF;CACF;;;;;CAMA,KACE,WACA,WACS;EACT,OAAO,KAAK,OAAO,KAAK,WAAW,SAAS;CAC9C;CAEA,UAAoC;EAClC,OAAO,KAAK,OAAO,QAAQ;CAC7B;CAEA,MAAgB;EACd,OAAO,SAAS,KAAK,OAAO,IAAI,IAAI,EAAE,YAAY,KAAK;CACzD;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgEA,SAAgB,yBACd,GAAG,SACyB;CAC5B,MAAM,SAA4B,CAAC;CACnC,MAAM,YAAmC,CAAC;CAC1C,MAAM,SAAoB,CAAC;CAE3B,KAAK,MAAM,UAAU,SACnB,OAAO,MACJ,YAAY,OAAO,KAAK,GAAG,QAAQ,MAAM,IACzC,YAAY;EACX,UAAU,KAAK,GAAG,QAAQ,SAAS;EACnC,OAAO,KAAK,QAAQ,KAAK;CAC3B,CACF;CAGF,OACE,OAAO,SACH,iBAAiB,KAAK,EAAE,OAAO,CAAC,IAChC,iBAAiB,QAAQ;EAAE;EAAW,OAAO;CAAO,CAAC;AAE7D;;;;AASA,SAAgB,cACd,KACA,KACU;CACV,OAAO,IAAI,SAAS,GAAQ;AAC9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeengbe/config",
3
- "version": "0.0.9",
3
+ "version": "1.0.0",
4
4
  "description": "A declarative, strongly typed schema for parsing and validating environment variables in TypeScript.",
5
5
  "keywords": [
6
6
  "config",
@@ -33,6 +33,10 @@
33
33
  ".": {
34
34
  "types": "./dist/index.d.mts",
35
35
  "default": "./dist/index.mjs"
36
+ },
37
+ "./validation": {
38
+ "types": "./dist/validation.d.mts",
39
+ "default": "./dist/validation.mjs"
36
40
  }
37
41
  },
38
42
  "main": "./dist/index.mjs",
@@ -45,7 +49,7 @@
45
49
  "!src/**/fake.ts"
46
50
  ],
47
51
  "dependencies": {
48
- "@jeengbe/prelude": "0.1.3"
52
+ "@jeengbe/prelude": "0.1.4"
49
53
  },
50
54
  "devDependencies": {
51
55
  "oxfmt": "^0.60.0",