@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/src/env.ts CHANGED
@@ -1,24 +1,194 @@
1
- import type { EnvSpec, InferEnvSpec, Pretty } from './ast.js';
1
+ import type { EnvSpec, ParseEnv, Pretty, ScalarValidationResultOrEither } from './ast.js';
2
2
  import { EnvNode, ScalarEnvNode } from './ast.js';
3
- import type { ValidationResult } from './validation.js';
4
- import { arrayIncludes, combineValidationResults } from './validation.js';
3
+ import { ValidationResult } from './validation.js';
4
+ import { arrayIncludes, collectValidationResults } from './validation.js';
5
5
  import { Either } from '@jeengbe/prelude';
6
6
 
7
+ /**
8
+ * Type-safe configuration loader.
9
+ *
10
+ * @example
11
+ *
12
+ * ```ts
13
+ * import { env } from '@jeengbe/config';
14
+ *
15
+ * const config = env.load({
16
+ * port: env.number('PORT', 3000),
17
+ * debug: env.boolean('DEBUG', false),
18
+ * driver: env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {
19
+ * memory: {},
20
+ * redis: { url: env.string('REDIS_URL') },
21
+ * }),
22
+ * });
23
+ *
24
+ * // -> {
25
+ * // port: number;
26
+ * // debug: boolean;
27
+ * // driver: { driver: 'memory' } | { driver: 'redis'; url: string };
28
+ * // }
29
+ * ```
30
+ *
31
+ * Variables are read from `process.env` and trimmed before validation. Only a fully unset variable
32
+ * falls back to a default value or fails as required - an explicitly empty value is passed through to
33
+ * validation like any other input.
34
+ */
7
35
  export interface Env {
36
+ /**
37
+ * Reads a string environment variable.
38
+ *
39
+ * @example
40
+ *
41
+ * ```ts
42
+ * const res = env.load(env.string('API_KEY'));
43
+ * // ^? string
44
+ *
45
+ * // API_KEY=abc-test -> 'abc-test'
46
+ * // API_KEY=123 -> '123'
47
+ * // API_KEY= -> ''
48
+ * ```
49
+ */
8
50
  string(key: string, defaultValue?: string): ScalarEnvNode<string>;
51
+
52
+ /**
53
+ * Reads a numeric environment variable. Must match `/^-?\d+(?:\.\d+)?$/`, i.e. `[-]digits[.digits]`.
54
+ *
55
+ * @example
56
+ *
57
+ * ```ts
58
+ * const res = env.load(env.number('PORT'));
59
+ * // ^? number
60
+ *
61
+ * // PORT=3000 -> 3000
62
+ * // PORT=-1 -> -1
63
+ * // PORT=abc -> error
64
+ * ```
65
+ */
9
66
  number(key: string, defaultValue?: number): ScalarEnvNode<number>;
67
+
68
+ /**
69
+ * Reads a boolean environment variable (must be 'true' or 'false').
70
+ *
71
+ * @example
72
+ *
73
+ * ```ts
74
+ * const res = env.load(env.boolean('DEBUG'));
75
+ * // ^? boolean
76
+ *
77
+ * // DEBUG=true -> true
78
+ * // DEBUG=false -> false
79
+ * // DEBUG=abc -> error
80
+ * ```
81
+ */
10
82
  boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean>;
83
+
84
+ /**
85
+ * Reads an environment variable constrained to one of the provided values.
86
+ *
87
+ * @example
88
+ *
89
+ * ```ts
90
+ * const res = env.load(env.enum('DRIVER', ['memory', 'redis']));
91
+ * // ^? 'memory' | 'redis'
92
+ *
93
+ * // DRIVER=memory -> 'memory'
94
+ * // DRIVER=abc -> error
95
+ * ```
96
+ */
11
97
  enum<const T extends string>(
12
98
  key: string,
13
99
  values: readonly T[],
14
100
  defaultValue?: T,
15
101
  ): ScalarEnvNode<T>;
16
- custom<T>(
102
+
103
+ /**
104
+ * Reads a comma-separated list environment variable, validating each item against the provided item type.
105
+ *
106
+ * @example
107
+ *
108
+ * ```ts
109
+ * const res = env.load(env.array(env.string('API_KEYS')));
110
+ * // ^? readonly string[]
111
+ *
112
+ * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
113
+ * // API_KEYS= -> []
114
+ * // API_KEYS=abc,,ghi -> error
115
+ * ```
116
+ *
117
+ * @example
118
+ *
119
+ * ```ts
120
+ * const res = env.load(env.array(env.string('API_KEYS'), ['default1', 'default2']));
121
+ * // ^? readonly string[]
122
+ *
123
+ * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
124
+ * // API_KEYS= -> []
125
+ * // (not set) -> ['default1', 'default2']
126
+ * // API_KEYS=abc,,ghi -> error
127
+ * ```
128
+ *
129
+ * @example
130
+ *
131
+ * ```ts
132
+ * const res = env.load(env.array(env.string('API_KEYS').optional()));
133
+ * // ^? readonly (string | undefined)[]
134
+ *
135
+ * // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
136
+ * // API_KEYS= -> []
137
+ * // API_KEYS=abc,,ghi -> ['abc', undefined, 'ghi']
138
+ * ```
139
+ */
140
+ array<const T>(
141
+ itemType: ScalarEnvNode<T>,
142
+ defaultValue?: readonly T[],
143
+ ): ScalarEnvNode<readonly T[]>;
144
+
145
+ /**
146
+ * Reads an environment variable, validating and transforming it with the provided function.
147
+ *
148
+ * @example
149
+ *
150
+ * ```ts
151
+ * const res = env.load(
152
+ * // ^? string
153
+ * env.scalar('TAG', (value, path) =>
154
+ * value.length === 3
155
+ * ? ValidationResult.success({ value, defaulted: [] })
156
+ * : ValidationResult.fail({
157
+ * errors: [{ path, key: 'TAG', message: 'must be 3 characters long', value }],
158
+ * }),
159
+ * ),
160
+ * );
161
+ *
162
+ * // TAG=abc -> 'abc'
163
+ * // TAG=abcd -> error
164
+ * ```
165
+ */
166
+ scalar<const T>(
17
167
  key: string,
18
- transform: (value: string) => ValidationResult<T>,
168
+ transform: (value: string, path: string) => ScalarValidationResultOrEither<T>,
19
169
  defaultValue?: T,
20
170
  ): ScalarEnvNode<T>;
21
- array<T>(itemType: ScalarEnvNode<T>, defaultValue?: readonly T[]): EnvNode<readonly T[]>;
171
+
172
+ /**
173
+ * Reads a discriminator environment variable and resolves the nested schema mapped to its value.
174
+ *
175
+ * @example
176
+ *
177
+ * ```ts
178
+ * const res = env.load(
179
+ * // ^? { driver: 'memory'; } | { driver: 'redis'; url: string; }
180
+ * env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {
181
+ * memory: {},
182
+ * redis: { url: env.string('REDIS_URL') },
183
+ * }),
184
+ * );
185
+ *
186
+ * // DRIVER=memory -> { driver: 'memory' }
187
+ * // DRIVER=redis, REDIS_URL=redis://localhost -> { driver: 'redis', url: 'redis://localhost' }
188
+ * // DRIVER=redis, REDIS_URL= -> { driver: 'redis', url: '' }
189
+ * // DRIVER=abc -> error
190
+ * ```
191
+ */
22
192
  discriminate<
23
193
  K extends string,
24
194
  V extends string,
@@ -28,83 +198,186 @@ export interface Env {
28
198
  discriminatorValueType: ScalarEnvNode<V>,
29
199
  mapping: M,
30
200
  ): EnvNode<DiscriminatorResult<K, V, M>>;
31
- load<S extends EnvSpec>(spec: S): Pretty<InferEnvSpec<S>>;
201
+
202
+ /**
203
+ * Validates and loads the provided schema from `process.env`. Returns a `ValidationResult` that contains either the parsed values or a list of validation errors.
204
+ *
205
+ * @example
206
+ *
207
+ * ```ts
208
+ * const result = env.parse({
209
+ * port: env.number('PORT'),
210
+ * debug: env.boolean('DEBUG'),
211
+ * });
212
+ *
213
+ * if (result.getLeft()) {
214
+ * console.error('Validation failed:', result.getLeft());
215
+ * } else {
216
+ * const config = result.get()!;
217
+ * // -> { port: number; debug: boolean; }
218
+ * }
219
+ * ```
220
+ */
221
+ parse<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): ValidationResult<Pretty<ParseEnv<S>>>;
222
+
223
+ /**
224
+ * Validates and loads the provided schema from `process.env`. Throws if any required variables are missing or invalid.
225
+ *
226
+ * @example
227
+ *
228
+ * ```ts
229
+ * const config = env.load({
230
+ * port: env.number('PORT', 3000),
231
+ * debug: env.boolean('DEBUG', false),
232
+ * }, console.log);
233
+ *
234
+ * // -> {
235
+ * // port: number;
236
+ * // debug: boolean;
237
+ * // }
238
+ * ```
239
+ */
240
+ load<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): Pretty<ParseEnv<S>>;
32
241
  }
33
242
 
34
243
  export const env: Env = {
35
- string(key, defaultValue) {
36
- return env.custom(key, (value) => Either.right(value), defaultValue);
244
+ string(key: string, defaultValue?: string): ScalarEnvNode<string> {
245
+ return env.scalar(
246
+ key,
247
+ (value): ValidationResult<string> => ValidationResult.success({ value, defaulted: [] }),
248
+ defaultValue,
249
+ );
37
250
  },
38
251
 
39
- number(key, defaultValue) {
40
- return env.custom(
252
+ number(key: string, defaultValue?: number): ScalarEnvNode<number> {
253
+ return env.scalar(
41
254
  key,
42
- (value) => {
255
+ (value, path): ValidationResult<number> => {
43
256
  if (/^-?\d+(?:\.\d+)?$/.test(value)) {
44
- return Either.right(Number(value));
257
+ return ValidationResult.success({ value: Number(value), defaulted: [] });
45
258
  }
46
259
 
47
- return Either.left(['invalid number']);
260
+ return ValidationResult.fail({
261
+ errors: [
262
+ {
263
+ path,
264
+ key,
265
+ message: 'invalid number',
266
+ value,
267
+ },
268
+ ],
269
+ });
48
270
  },
49
271
  defaultValue,
50
272
  );
51
273
  },
52
274
 
53
- boolean(key, defaultValue) {
54
- return env.custom(
275
+ boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean> {
276
+ return env.scalar(
55
277
  key,
56
- (value) => {
57
- if (value.toLowerCase() === 'true') return Either.right(true);
58
- if (value.toLowerCase() === 'false') return Either.right(false);
278
+ (value, path): ValidationResult<boolean> => {
279
+ if (value.toLowerCase() === 'true') {
280
+ return ValidationResult.success({ value: true, defaulted: [] });
281
+ }
282
+
283
+ if (value.toLowerCase() === 'false') {
284
+ return ValidationResult.success({ value: false, defaulted: [] });
285
+ }
59
286
 
60
- return Either.left(["invalid boolean (must be 'true' or 'false')"]);
287
+ return ValidationResult.fail({
288
+ errors: [
289
+ {
290
+ path,
291
+ key,
292
+ message: 'invalid boolean',
293
+ formatHint: "must be 'true' or 'false'",
294
+ value,
295
+ },
296
+ ],
297
+ });
61
298
  },
62
299
  defaultValue,
63
300
  );
64
301
  },
65
302
 
66
- enum(key, values, defaultValue) {
67
- return env.custom(
303
+ enum<const T extends string>(
304
+ key: string,
305
+ values: readonly T[],
306
+ defaultValue?: T,
307
+ ): ScalarEnvNode<T> {
308
+ return env.scalar(
68
309
  key,
69
- (value) => {
70
- if (arrayIncludes(values, value)) return Either.right(value);
310
+ (value, path): ValidationResult<T> => {
311
+ if (arrayIncludes(values, value)) return ValidationResult.success({ value, defaulted: [] });
71
312
 
72
- return Either.left([
73
- `invalid enum value (must be one of: ${values.map((v) => `'${v}'`).join(', ')})`,
74
- ]);
313
+ return ValidationResult.fail({
314
+ errors: [
315
+ {
316
+ path,
317
+ key,
318
+ message: 'invalid enum value',
319
+ formatHint: `must be one of: ${values.map((v) => `'${v}'`).join(', ')}`,
320
+ value,
321
+ },
322
+ ],
323
+ });
75
324
  },
76
325
  defaultValue,
77
326
  );
78
327
  },
79
328
 
80
- custom(key, transform, defaultValue) {
81
- return new ScalarEnvNode(key, (value) => {
82
- if (value === undefined) {
83
- if (defaultValue !== undefined) return Either.right(defaultValue);
84
- return Either.left(['required']);
85
- }
329
+ array<const T>(
330
+ itemType: ScalarEnvNode<T>,
331
+ defaultValue?: readonly T[],
332
+ ): ScalarEnvNode<readonly T[]> {
333
+ return env.scalar(
334
+ itemType.key,
335
+ (value, path): ValidationResult<readonly T[]> => {
336
+ // Special case because ''.split(',') returns [''] instead of [].
337
+ if (value === '') return ValidationResult.success({ value: [], defaulted: [] });
86
338
 
87
- return transform(value);
88
- });
339
+ return collectValidationResults(
340
+ ...value
341
+ .split(',')
342
+ .map((v) => v.trim() || undefined)
343
+ .map((v, i) =>
344
+ itemType.validate(
345
+ (k) =>
346
+ // itemType always reads its own key, so this is never false
347
+ k === itemType.key ? v : /* v8 ignore next */ undefined,
348
+ `${path}.${i}`,
349
+ ),
350
+ ),
351
+ );
352
+ },
353
+ defaultValue,
354
+ );
89
355
  },
90
356
 
91
- array<T>(itemType: ScalarEnvNode<T>, defaultValue?: readonly T[]): EnvNode<readonly T[]> {
92
- return new EnvNode((path, loadValue) => {
93
- const value = loadValue(itemType.key);
357
+ scalar<T>(
358
+ key: string,
359
+ transform: (value: string, path: string) => ScalarValidationResultOrEither<T>,
360
+ defaultValue?: T,
361
+ ): ScalarEnvNode<T> {
362
+ return new ScalarEnvNode(key, (value, path): ScalarValidationResultOrEither<T> => {
94
363
  if (value === undefined) {
95
- if (defaultValue !== undefined) return Either.right(defaultValue);
96
- return Either.left([`${itemType.key} (${path}): required`]);
364
+ if (defaultValue !== undefined) {
365
+ return ValidationResult.success({
366
+ value: defaultValue,
367
+ defaulted: [
368
+ {
369
+ path,
370
+ key,
371
+ defaultValue,
372
+ },
373
+ ],
374
+ });
375
+ }
376
+
377
+ return Either.left('required');
97
378
  }
98
- if (value === '') return Either.right([]);
99
-
100
- return combineValidationResults(
101
- ...value
102
- .split(',')
103
- .map((v) => v.trim() || undefined)
104
- .map((v, i) =>
105
- itemType.validate(`${path}.${i}`, (k) => (k === itemType.key ? v : undefined)),
106
- ),
107
- );
379
+
380
+ return transform(value, path);
108
381
  });
109
382
  },
110
383
 
@@ -117,28 +390,43 @@ export const env: Env = {
117
390
  discriminatorValueType: ScalarEnvNode<V>,
118
391
  mapping: M,
119
392
  ): EnvNode<DiscriminatorResult<K, V, M>> {
120
- return new EnvNode((path, loadValue) => {
121
- return discriminatorValueType.validate(path, loadValue).flatMap((discriminatorValue) => {
122
- return resolveNode<NonNullable<M[keyof M]> | {}>(
123
- path,
124
- mapping[discriminatorValue] ?? {},
125
- loadValue,
126
- ).map(
127
- (mappingValues) =>
128
- ({
129
- [discriminatorKey]: discriminatorValue,
130
- ...mappingValues,
131
- }) as DiscriminatorResult<K, V, M>,
132
- );
133
- });
393
+ return new EnvNode((loadValue, path): ValidationResult<DiscriminatorResult<K, V, M>> => {
394
+ return discriminatorValueType
395
+ .validate(loadValue, `${path}.${discriminatorKey}`)
396
+ .flatMap((discriminatorValue) => {
397
+ return resolveNode<NonNullable<M[keyof M]> | {}>(
398
+ path,
399
+ mapping[discriminatorValue] ?? {},
400
+ loadValue,
401
+ ).map(
402
+ (mappingValues) =>
403
+ ({
404
+ [discriminatorKey]: discriminatorValue,
405
+ ...mappingValues,
406
+ }) as DiscriminatorResult<K, V, M>,
407
+ );
408
+ });
134
409
  });
135
410
  },
136
411
 
137
- load(spec) {
138
- return resolveNode('$', spec, (key) => process.env[key]?.trim() || undefined).getOrElse(
139
- (errors) => {
140
- throw new Error(`Failed to load config: ${errors.join(', ')}`);
412
+ parse<S extends EnvSpec>(spec: S, env = process.env): ValidationResult<Pretty<ParseEnv<S>>> {
413
+ return resolveNode('$', spec, (key) => env[key]?.trim());
414
+ },
415
+
416
+ load<S extends EnvSpec>(spec: S, env = process.env): Pretty<ParseEnv<S>> {
417
+ return this.parse(spec, env).fold(
418
+ (failure) => {
419
+ throw new Error(
420
+ `Environment validation failed:\n${failure.errors
421
+ .map(
422
+ (e) =>
423
+ // oxlint-disable-next-line typescript/no-base-to-string
424
+ ` ${e.path} (${e.key}): ${e.message} (${[e.formatHint, `got: '${String(e.value ?? '<not provided>')}'`].filter((x) => x).join('; ')})`,
425
+ )
426
+ .join('\n')}`,
427
+ );
141
428
  },
429
+ ({ value }) => value,
142
430
  );
143
431
  },
144
432
  };
@@ -150,21 +438,21 @@ type DiscriminatorResult<
150
438
  > = Pretty<
151
439
  // This "redundant" condition is necessary to make sure that 'DiscriminatorResult' distributes over
152
440
  // the union type V
153
- V extends unknown ? Record<K, V> & InferEnvSpec<M[V] extends EnvSpec ? M[V] : {}> : never
441
+ V extends unknown ? Record<K, V> & ParseEnv<M[V] extends EnvSpec ? M[V] : {}> : never
154
442
  >;
155
443
 
156
444
  function resolveNode<S extends EnvSpec>(
157
445
  path: string,
158
446
  spec: S,
159
447
  loadValue: (key: string) => string | undefined,
160
- ): ValidationResult<Pretty<InferEnvSpec<S>>> {
448
+ ): ValidationResult<Pretty<ParseEnv<S>>> {
161
449
  if (spec instanceof EnvNode) {
162
- return (spec as EnvNode<Pretty<InferEnvSpec<S>>>).validate(path, loadValue);
450
+ return (spec as EnvNode<Pretty<ParseEnv<S>>>).validate(loadValue, path);
163
451
  }
164
452
 
165
- return combineValidationResults(
453
+ return collectValidationResults(
166
454
  ...Object.entries(spec).map(([key, value]) =>
167
455
  resolveNode(`${path}.${key}`, value, loadValue).map((v) => [key, v] as const),
168
456
  ),
169
- ).map((entries) => Object.fromEntries(entries) as Pretty<InferEnvSpec<S>>);
457
+ ).map((entries) => Object.fromEntries(entries) as Pretty<ParseEnv<S>>);
170
458
  }
package/src/if-enabled.ts CHANGED
@@ -1,6 +1,5 @@
1
- import type { EnvNode, EnvSpec, InferEnvSpec } from './ast.js';
1
+ import type { EnvNode, EnvSpec, ParseEnv } from './ast.js';
2
2
  import { env } from './env.js';
3
- import type { ValidationResult } from './validation.js';
4
3
  import { Either } from '@jeengbe/prelude';
5
4
 
6
5
  type IfEnabled<T> =
@@ -9,25 +8,34 @@ type IfEnabled<T> =
9
8
  } & Omit<T, 'enabled'>)
10
9
  | { enabled: false };
11
10
 
11
+ /**
12
+ * Wraps a config schema behind an `enabled` boolean flag read from `envVar`. When disabled, none of the
13
+ * nested schema's environment variables are validated or required.
14
+ *
15
+ * @example
16
+ *
17
+ * ```ts
18
+ * ifEnabled('FEATURE_X', { apiKey: env.string('FEATURE_X_API_KEY') });
19
+ * // -> { enabled: true; apiKey: string } | { enabled: false }
20
+ * ```
21
+ */
12
22
  export function ifEnabled<T extends Record<string, EnvSpec>>(
13
23
  envVar: string,
14
24
  config: T,
15
25
  defaultEnabled = false,
16
- ): EnvNode<IfEnabled<InferEnvSpec<T>>> {
26
+ ): EnvNode<IfEnabled<ParseEnv<T>>> {
17
27
  // Since discriminate only works with string values, we need to bridge 'true' -> 'enabled' -> true
18
28
  return env
19
29
  .discriminate(
20
30
  'enabled',
21
31
  env
22
32
  .boolean(envVar, defaultEnabled)
23
- .transform(
24
- (v): ValidationResult<'enabled' | 'disabled'> => Either.right(v ? 'enabled' : 'disabled'),
25
- ),
33
+ .transform<'enabled' | 'disabled'>((v) => Either.right(v ? 'enabled' : 'disabled')),
26
34
  {
27
35
  enabled: config,
28
36
  },
29
37
  )
30
- .transform((value): ValidationResult<IfEnabled<InferEnvSpec<T>>> => {
38
+ .transform((value): Either<never, IfEnabled<ParseEnv<T>>> => {
31
39
  if (value.enabled === 'enabled') {
32
40
  return Either.right({
33
41
  ...value,
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
- export type { EnvNode, EnvSpec, InferEnvSpec } from './ast.js';
1
+ export type { EnvNode, ScalarEnvNode, EnvSpec, ParseEnv } from './ast.js';
2
2
  export { env } from './env.js';
3
3
  export { ifEnabled } from './if-enabled.js';
4
+ export { collectValidationResults, ValidationResult } from './validation.js';
5
+ export type {
6
+ ValidationDefaulted,
7
+ ValidationError,
8
+ ValidationFailure,
9
+ ValidationSuccess,
10
+ } from './validation.js';