@jeengbe/config 0.0.8 → 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/README.md +127 -0
- package/dist/ast.d.mts +108 -8
- package/dist/ast.d.mts.map +1 -1
- package/dist/ast.mjs +113 -7
- package/dist/ast.mjs.map +1 -1
- package/dist/env.d.mts +203 -5
- package/dist/env.d.mts.map +1 -1
- package/dist/env.mjs +75 -35
- package/dist/env.mjs.map +1 -1
- package/dist/if-enabled.d.mts +13 -2
- package/dist/if-enabled.d.mts.map +1 -1
- package/dist/if-enabled.mjs +11 -0
- package/dist/if-enabled.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +2 -1
- package/dist/validation.d.mts +103 -6
- package/dist/validation.d.mts.map +1 -1
- package/dist/validation.mjs +109 -5
- package/dist/validation.mjs.map +1 -1
- package/package.json +6 -2
- package/src/ast.ts +211 -19
- package/src/env.ts +361 -73
- package/src/if-enabled.ts +15 -7
- package/src/index.ts +8 -1
- package/src/validation.ts +162 -12
package/README.md
CHANGED
|
@@ -1,9 +1,136 @@
|
|
|
1
1
|
<h1 align="center">@jeengbe/config</h1>
|
|
2
2
|
<div align="center">
|
|
3
3
|
|
|
4
|
+
A declarative, strongly typed schema for parsing and validating environment variables in TypeScript.
|
|
5
|
+
|
|
4
6
|
[](https://github.com/jeengbe/ts-packages/blob/master/packages/config/LICENSE)
|
|
5
7
|
[](https://www.npmjs.com/package/@jeengbe/config)
|
|
6
8
|
[](https://jsr.io/@jeengbe/config)
|
|
7
9
|
[](https://app.codecov.io/gh/jeengbe/ts-packages/tree/master/packages/config)
|
|
8
10
|
|
|
9
11
|
</div>
|
|
12
|
+
|
|
13
|
+
Define your environment variables once as a schema, and get back a plain, fully typed config object. Missing or invalid values are collected across the whole schema and reported together, so you find out about every misconfigured variable at once, rather than one crash at a time.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
The package is published to [npm](https://www.npmjs.com/package/@jeengbe/config) and [JSR](https://jsr.io/@jeengbe/config) as `@jeengbe/config`. Versions follow Semantic Versioning.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Defining and loading a schema
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { env } from '@jeengbe/config';
|
|
25
|
+
|
|
26
|
+
const config = env.load({
|
|
27
|
+
port: env.number('PORT', 3000),
|
|
28
|
+
host: env.string('HOST', '0.0.0.0'),
|
|
29
|
+
logLevel: env.enum('LOG_LEVEL', ['debug', 'info', 'warn', 'error'], 'info'),
|
|
30
|
+
});
|
|
31
|
+
// config: { port: number; host: string; logLevel: 'debug' | 'info' | 'warn' | 'error' }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`env.load` reads from `process.env` (values are trimmed, and a missing or whitespace-only value is treated as absent), validates every field, and returns a plain object typed to match the schema. If anything is missing or invalid, it throws a single error combining every failure:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Failed to load config: FOO ($.foo): required, NUM ($.num): invalid number
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Schemas nest using plain objects:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const config = env.load({
|
|
44
|
+
server: {
|
|
45
|
+
port: env.number('PORT', 3000),
|
|
46
|
+
},
|
|
47
|
+
database: {
|
|
48
|
+
url: env.string('DATABASE_URL'),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// config: { server: { port: number }; database: { url: string } }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Scalars
|
|
55
|
+
|
|
56
|
+
- `env.string(key, defaultValue?)`
|
|
57
|
+
- `env.number(key, defaultValue?)` — accepts integers and decimals, including negative numbers.
|
|
58
|
+
- `env.boolean(key, defaultValue?)` — accepts `'true'`/`'false'`, case-insensitively.
|
|
59
|
+
- `env.enum(key, values, defaultValue?)` — restricts the value to one of a fixed list, typed as a literal union of `values`.
|
|
60
|
+
|
|
61
|
+
Without a `defaultValue`, all of these are required and fail validation when the variable is missing.
|
|
62
|
+
|
|
63
|
+
### Custom scalars (`env.scalar`)
|
|
64
|
+
|
|
65
|
+
For anything else, write your own parser with `env.scalar`. It returns a `ValidationResult<T>` (an `Either<readonly string[], T>` from `@jeengbe/prelude`):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { env } from '@jeengbe/config';
|
|
69
|
+
import { Either } from '@jeengbe/prelude';
|
|
70
|
+
|
|
71
|
+
const apiUrl = env.scalar('API_URL', (value) => {
|
|
72
|
+
try {
|
|
73
|
+
return Either.right(new URL(value));
|
|
74
|
+
} catch {
|
|
75
|
+
return Either.left(['must be a valid URL']);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Optional values (`.optional()`)
|
|
81
|
+
|
|
82
|
+
Any scalar node can be made optional. This resolves to `undefined` when the variable is missing, ignoring the default value on the underlying node, instead of failing validation:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const timeoutMs = env.number('TIMEOUT_MS').optional();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Transforming values (`.transform()`)
|
|
89
|
+
|
|
90
|
+
Every node can be transformed into a different value. The transform function receives the already-validated value and itself returns a `ValidationResult`, so it can also fail validation:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const port = env
|
|
94
|
+
.number('PORT')
|
|
95
|
+
.transform((n) =>
|
|
96
|
+
n > 0 && n < 65536 ? Either.right(n) : Either.left(['must be between 1 and 65535']),
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Arrays (`env.array`)
|
|
101
|
+
|
|
102
|
+
`env.array` splits a comma-separated string and validates each item against a scalar node:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const ports = env.array(env.number('PORTS'));
|
|
106
|
+
// PORTS="3000,3001,3002" -> [3000, 3001, 3002]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
An empty item between commas (e.g. `"1,,3"`) is treated as `undefined` for the item schema (mark the item node `.optional()` to allow that). A missing variable falls back to the array's own `defaultValue`, if one was given; the item schema's default is not applied per-missing-item.
|
|
110
|
+
|
|
111
|
+
### Discriminated variants (`env.discriminate`)
|
|
112
|
+
|
|
113
|
+
Use `env.discriminate` to pick between several shapes based on the value of another variable, similar to a discriminated union:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const storage = env.discriminate('type', env.enum('STORAGE_TYPE', ['s3', 'local']), {
|
|
117
|
+
s3: { bucket: env.string('S3_BUCKET') },
|
|
118
|
+
local: { path: env.string('LOCAL_PATH') },
|
|
119
|
+
});
|
|
120
|
+
// storage: { type: 's3'; bucket: string } | { type: 'local'; path: string }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Feature flags (`ifEnabled`)
|
|
124
|
+
|
|
125
|
+
`ifEnabled` wraps `env.discriminate` for the common case of gating a block of config behind a boolean flag:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { ifEnabled } from '@jeengbe/config';
|
|
129
|
+
|
|
130
|
+
const feature = ifEnabled('FEATURE_ENABLED', {
|
|
131
|
+
apiKey: env.string('FEATURE_API_KEY'),
|
|
132
|
+
});
|
|
133
|
+
// feature: { enabled: true; apiKey: string } | { enabled: false }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This resolves to `{ enabled: true, apiKey: string }` when `FEATURE_ENABLED` is `'true'`, or `{ enabled: false }` otherwise.
|
package/dist/ast.d.mts
CHANGED
|
@@ -1,24 +1,124 @@
|
|
|
1
|
-
import { ValidationResult } from "./validation.mjs";
|
|
1
|
+
import { ValidationFailure, ValidationResult } from "./validation.mjs";
|
|
2
|
+
import { Either } from "@jeengbe/prelude";
|
|
2
3
|
//#region src/ast.d.ts
|
|
3
4
|
declare const nodeType: unique symbol;
|
|
5
|
+
/**
|
|
6
|
+
* A node in a config schema that validates a value out of the loaded environment.
|
|
7
|
+
*
|
|
8
|
+
* Do not instantiate this class directly; use the {@link Env} interface instead.
|
|
9
|
+
*/
|
|
4
10
|
declare class EnvNode<T> {
|
|
5
|
-
readonly validate: (path: string, loadValue: (key: string) => string | undefined) => ValidationResult<T>;
|
|
6
11
|
private readonly [nodeType];
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
readonly validate: (loadValue: (key: string) => string | undefined, path: string) => ValidationResult<T>;
|
|
13
|
+
constructor(validate: (loadValue: (key: string) => string | undefined, path: string) => ValidationResultOrEither<T>);
|
|
14
|
+
/**
|
|
15
|
+
* Transforms the validated value of this node into a new value, or fails validation.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const res = env.load(
|
|
21
|
+
* // ^? number
|
|
22
|
+
* env.number('PORT').transform((port, path) =>
|
|
23
|
+
* port > 0
|
|
24
|
+
* ? ValidationResult.success({ value: port, defaulted: [] })
|
|
25
|
+
* : ValidationResult.fail({
|
|
26
|
+
* errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],
|
|
27
|
+
* }),
|
|
28
|
+
* ),
|
|
29
|
+
* );
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
transform<U>(transform: (value: T, path: string) => ValidationResultOrEither<U>): EnvNode<U>;
|
|
9
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* An {@link EnvNode} backed by a single environment variable.
|
|
36
|
+
*
|
|
37
|
+
* Do not instantiate this class directly; use the {@link Env} interface instead.
|
|
38
|
+
*/
|
|
10
39
|
declare class ScalarEnvNode<T> extends EnvNode<T> {
|
|
11
40
|
readonly key: string;
|
|
12
41
|
private readonly validateValue;
|
|
13
|
-
constructor(key: string, validateValue: (value: string | undefined) =>
|
|
42
|
+
constructor(key: string, validateValue: (value: string | undefined, path: string) => ScalarValidationResultOrEither<T>);
|
|
43
|
+
/**
|
|
44
|
+
* Returns a new ScalarEnvNode that resolves to undefined instead of failing validation when the environment variable is not set.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* const res = env.load(
|
|
50
|
+
* // ^? number | undefined
|
|
51
|
+
* env.number('PORT').optional(),
|
|
52
|
+
* );
|
|
53
|
+
*
|
|
54
|
+
* // PORT=3000 -> 3000
|
|
55
|
+
* // PORT= -> error
|
|
56
|
+
* // (not set) -> undefined
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
14
59
|
optional(): ScalarEnvNode<T | undefined>;
|
|
15
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Transforms the validated value of this node into a new value, or fails validation.
|
|
62
|
+
*
|
|
63
|
+
* As a shorthand for the common case of a single error message, `transform` may return a plain
|
|
64
|
+
* `Either<string, U>` instead of a full `ValidationResult<U>`. The error string is automatically
|
|
65
|
+
* wrapped into a {@link ValidationError} using this node's key, its path, and the value that failed to
|
|
66
|
+
* transform.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* const res = env.load(
|
|
72
|
+
* // ^? number
|
|
73
|
+
* env.number('PORT').transform((port) =>
|
|
74
|
+
* port > 0 ? Either.right(port) : Either.left('must be positive'),
|
|
75
|
+
* ),
|
|
76
|
+
* );
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* const res = env.load(
|
|
83
|
+
* // ^? number
|
|
84
|
+
* env.number('PORT').transform((port, path) =>
|
|
85
|
+
* port > 0
|
|
86
|
+
* ? ValidationResult.success({ value: port, defaulted: [] })
|
|
87
|
+
* : ValidationResult.fail({
|
|
88
|
+
* errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],
|
|
89
|
+
* }),
|
|
90
|
+
* ),
|
|
91
|
+
* );
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
transform<U>(transform: (value: T, path: string) => ScalarValidationResultOrEither<U>): ScalarEnvNode<U>;
|
|
16
95
|
}
|
|
96
|
+
type ValidationResultOrEither<T> = ValidationResult<T> | Either<ValidationFailure, T>;
|
|
97
|
+
type ScalarValidationResultOrEither<T> = ValidationResultOrEither<T> | Either<string, T>;
|
|
98
|
+
/**
|
|
99
|
+
* Describes the shape of a config schema: either a single EnvNode, or a nested object of them.
|
|
100
|
+
*/
|
|
17
101
|
type EnvSpec = EnvNode<unknown> | {
|
|
18
102
|
[key: string]: EnvSpec;
|
|
19
103
|
};
|
|
20
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Infers the resulting value type produced by loading an EnvSpec.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* export function createModuleConfig() {
|
|
110
|
+
* return {
|
|
111
|
+
* url: env.string('URL'),
|
|
112
|
+
* port: env.number('PORT').optional(),
|
|
113
|
+
* };
|
|
114
|
+
* }
|
|
115
|
+
*
|
|
116
|
+
* export type ModuleConfig = ParseEnv<ReturnType<typeof createModuleConfig>>;
|
|
117
|
+
* // ^? { url: string; port?: number | undefined; }
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
type ParseEnv<T extends EnvSpec> = T extends EnvNode<infer U> ? U : T extends Record<string, EnvSpec> ? { [K in keyof T]: ParseEnv<T[K]>; } : never;
|
|
21
121
|
type Pretty<T> = T extends (infer U extends object) ? { [K in keyof U]: Pretty<U[K]>; } : T;
|
|
22
122
|
//#endregion
|
|
23
|
-
export { EnvNode, EnvSpec,
|
|
123
|
+
export { EnvNode, EnvSpec, ParseEnv, Pretty, ScalarEnvNode, ScalarValidationResultOrEither, ValidationResultOrEither };
|
|
24
124
|
//# sourceMappingURL=ast.d.mts.map
|
package/dist/ast.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ast.d.mts","names":[],"sources":["../src/ast.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"ast.d.mts","names":[],"sources":["../src/ast.ts"],"mappings":";;;cAQM;;;;;;cAOO,QAAQ;oBACO;WACjB,WACP,YAAY,oCACZ,iBACG,iBAAiB;EAEtB,YACE,WACE,YAAY,oCACZ,iBACG,yBAAyB;;;;;;;;;;;;;;;;;;;EAuBhC,UAAU,GAAG,YAAY,OAAO,GAAG,iBAAiB,yBAAyB,KAAK,QAAQ;;;;;;;cAc/E,cAAc,WAAW,QAAQ;WAEjC;mBACQ;EAFnB,YACW,aACQ,gBACf,2BACA,iBACG,+BAA+B;;;;;;;;;;;;;;;;;EAyBtC,YAAY,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmDjB,UAAU,GACjB,YAAY,OAAO,GAAG,iBAAiB,+BAA+B,KACrE,cAAc;;KAcP,yBAAyB,KAAK,iBAAiB,KAAK,OAAO,mBAAmB;KAmB9E,+BAA+B,KAAK,yBAAyB,KAAK,eAAe;;;;KAuCjF,UAAU;GAAsB,cAAc;;;;;;;;;;;;;;;;;;KAkB9C,SAAS,UAAU,WAC7B,UAAU,cAAc,KACpB,IACA,UAAU,eAAe,cACpB,WAAW,IAAI,SAAS,EAAE;KAGzB,OAAO,KAAK,iBAAgB,uBAAsB,WAAW,IAAI,OAAO,EAAE,SAAQ"}
|
package/dist/ast.mjs
CHANGED
|
@@ -1,32 +1,138 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ValidationResult } from "./validation.mjs";
|
|
2
|
+
import { EitherBase } from "@jeengbe/prelude";
|
|
2
3
|
|
|
3
4
|
//#region src/ast.ts
|
|
5
|
+
/**
|
|
6
|
+
* A node in a config schema that validates a value out of the loaded environment.
|
|
7
|
+
*
|
|
8
|
+
* Do not instantiate this class directly; use the {@link Env} interface instead.
|
|
9
|
+
*/
|
|
4
10
|
var EnvNode = class EnvNode {
|
|
5
11
|
validate;
|
|
6
12
|
constructor(validate) {
|
|
7
|
-
this.validate = validate;
|
|
13
|
+
this.validate = (loadValue, path) => recoverValidationResultOrEither(validate(loadValue, path));
|
|
8
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Transforms the validated value of this node into a new value, or fails validation.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const res = env.load(
|
|
22
|
+
* // ^? number
|
|
23
|
+
* env.number('PORT').transform((port, path) =>
|
|
24
|
+
* port > 0
|
|
25
|
+
* ? ValidationResult.success({ value: port, defaulted: [] })
|
|
26
|
+
* : ValidationResult.fail({
|
|
27
|
+
* errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],
|
|
28
|
+
* }),
|
|
29
|
+
* ),
|
|
30
|
+
* );
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
9
33
|
transform(transform) {
|
|
10
|
-
return new EnvNode((
|
|
34
|
+
return new EnvNode((loadValue, path) => this.validate(loadValue, path).flatMap((value) => recoverValidationResultOrEither(transform(value, path))));
|
|
11
35
|
}
|
|
12
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* An {@link EnvNode} backed by a single environment variable.
|
|
39
|
+
*
|
|
40
|
+
* Do not instantiate this class directly; use the {@link Env} interface instead.
|
|
41
|
+
*/
|
|
13
42
|
var ScalarEnvNode = class ScalarEnvNode extends EnvNode {
|
|
14
43
|
key;
|
|
15
44
|
validateValue;
|
|
16
45
|
constructor(key, validateValue) {
|
|
17
|
-
super((
|
|
18
|
-
|
|
46
|
+
super((loadValue, path) => {
|
|
47
|
+
const value = loadValue(key);
|
|
48
|
+
return recoverScalarValidationResultOrEither(validateValue(value, path), path, key, value);
|
|
19
49
|
});
|
|
20
50
|
this.key = key;
|
|
21
51
|
this.validateValue = validateValue;
|
|
22
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns a new ScalarEnvNode that resolves to undefined instead of failing validation when the environment variable is not set.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* const res = env.load(
|
|
60
|
+
* // ^? number | undefined
|
|
61
|
+
* env.number('PORT').optional(),
|
|
62
|
+
* );
|
|
63
|
+
*
|
|
64
|
+
* // PORT=3000 -> 3000
|
|
65
|
+
* // PORT= -> error
|
|
66
|
+
* // (not set) -> undefined
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
23
69
|
optional() {
|
|
24
|
-
return new ScalarEnvNode(this.key, (value) => value === void 0 ?
|
|
70
|
+
return new ScalarEnvNode(this.key, (value, path) => value === void 0 ? ValidationResult.success({
|
|
71
|
+
value: void 0,
|
|
72
|
+
defaulted: [{
|
|
73
|
+
path,
|
|
74
|
+
key: this.key,
|
|
75
|
+
defaultValue: void 0
|
|
76
|
+
}]
|
|
77
|
+
}) : this.validateValue(value, path));
|
|
25
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Transforms the validated value of this node into a new value, or fails validation.
|
|
81
|
+
*
|
|
82
|
+
* As a shorthand for the common case of a single error message, `transform` may return a plain
|
|
83
|
+
* `Either<string, U>` instead of a full `ValidationResult<U>`. The error string is automatically
|
|
84
|
+
* wrapped into a {@link ValidationError} using this node's key, its path, and the value that failed to
|
|
85
|
+
* transform.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* const res = env.load(
|
|
91
|
+
* // ^? number
|
|
92
|
+
* env.number('PORT').transform((port) =>
|
|
93
|
+
* port > 0 ? Either.right(port) : Either.left('must be positive'),
|
|
94
|
+
* ),
|
|
95
|
+
* );
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* const res = env.load(
|
|
102
|
+
* // ^? number
|
|
103
|
+
* env.number('PORT').transform((port, path) =>
|
|
104
|
+
* port > 0
|
|
105
|
+
* ? ValidationResult.success({ value: port, defaulted: [] })
|
|
106
|
+
* : ValidationResult.fail({
|
|
107
|
+
* errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],
|
|
108
|
+
* }),
|
|
109
|
+
* ),
|
|
110
|
+
* );
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
26
113
|
transform(transform) {
|
|
27
|
-
return new ScalarEnvNode(this.key, (value) => this.validateValue(value).flatMap(transform));
|
|
114
|
+
return new ScalarEnvNode(this.key, (value, path) => recoverScalarValidationResultOrEither(this.validateValue(value, path), path, this.key, value).flatMap((val) => recoverScalarValidationResultOrEither(transform(val, path), path, this.key, value)));
|
|
28
115
|
}
|
|
29
116
|
};
|
|
117
|
+
function recoverValidationResultOrEither(result) {
|
|
118
|
+
if (result instanceof EitherBase) return result.fold((error) => ValidationResult.fail(error), (success) => ValidationResult.success({
|
|
119
|
+
value: success,
|
|
120
|
+
defaulted: []
|
|
121
|
+
}));
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
function recoverScalarValidationResultOrEither(result, path, key, value) {
|
|
125
|
+
if (result instanceof EitherBase) return result.fold((error) => ValidationResult.fail(typeof error === "object" ? error : { errors: [{
|
|
126
|
+
path,
|
|
127
|
+
key,
|
|
128
|
+
message: error,
|
|
129
|
+
value
|
|
130
|
+
}] }), (success) => ValidationResult.success({
|
|
131
|
+
value: success,
|
|
132
|
+
defaulted: []
|
|
133
|
+
}));
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
30
136
|
|
|
31
137
|
//#endregion
|
|
32
138
|
export { EnvNode, ScalarEnvNode };
|
package/dist/ast.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ast.mjs","names":[],"sources":["../src/ast.ts"],"sourcesContent":["import
|
|
1
|
+
{"version":3,"file":"ast.mjs","names":[],"sources":["../src/ast.ts"],"sourcesContent":["import { ValidationResult } from './validation.js';\nimport { Either, EitherBase } from '@jeengbe/prelude';\n\n// oxlint-disable-next-line no-unused-vars -- Imported for JSDoc\nimport type { Env } from './env.js';\n// oxlint-disable-next-line no-unused-vars -- Imported for JSDoc\nimport type { ValidationError, ValidationFailure } from './validation.js';\n\nconst nodeType = Symbol('type');\n\n/**\n * A node in a config schema that validates a value out of the loaded environment.\n *\n * Do not instantiate this class directly; use the {@link Env} interface instead.\n */\nexport class EnvNode<T> {\n declare private readonly [nodeType]: T;\n readonly validate: (\n loadValue: (key: string) => string | undefined,\n path: string,\n ) => ValidationResult<T>;\n\n constructor(\n validate: (\n loadValue: (key: string) => string | undefined,\n path: string,\n ) => ValidationResultOrEither<T>,\n ) {\n this.validate = (loadValue, path) => recoverValidationResultOrEither(validate(loadValue, path));\n }\n\n /**\n * Transforms the validated value of this node into a new value, or fails validation.\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? number\n * env.number('PORT').transform((port, path) =>\n * port > 0\n * ? ValidationResult.success({ value: port, defaulted: [] })\n * : ValidationResult.fail({\n * errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],\n * }),\n * ),\n * );\n * ```\n */\n transform<U>(transform: (value: T, path: string) => ValidationResultOrEither<U>): EnvNode<U> {\n return new EnvNode((loadValue, path) =>\n this.validate(loadValue, path).flatMap((value) =>\n recoverValidationResultOrEither(transform(value, path)),\n ),\n );\n }\n}\n\n/**\n * An {@link EnvNode} backed by a single environment variable.\n *\n * Do not instantiate this class directly; use the {@link Env} interface instead.\n */\nexport class ScalarEnvNode<T> extends EnvNode<T> {\n constructor(\n readonly key: string,\n private readonly validateValue: (\n value: string | undefined,\n path: string,\n ) => ScalarValidationResultOrEither<T>,\n ) {\n super((loadValue, path) => {\n const value = loadValue(key);\n\n return recoverScalarValidationResultOrEither(validateValue(value, path), path, key, value);\n });\n }\n\n /**\n * Returns a new ScalarEnvNode that resolves to undefined instead of failing validation when the environment variable is not set.\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? number | undefined\n * env.number('PORT').optional(),\n * );\n *\n * // PORT=3000 -> 3000\n * // PORT= -> error\n * // (not set) -> undefined\n * ```\n */\n optional(): ScalarEnvNode<T | undefined> {\n return new ScalarEnvNode<T | undefined>(this.key, (value, path) =>\n value === undefined\n ? ValidationResult.success({\n value: undefined,\n defaulted: [\n {\n path,\n key: this.key,\n defaultValue: undefined,\n },\n ],\n })\n : this.validateValue(value, path),\n );\n }\n\n /**\n * Transforms the validated value of this node into a new value, or fails validation.\n *\n * As a shorthand for the common case of a single error message, `transform` may return a plain\n * `Either<string, U>` instead of a full `ValidationResult<U>`. The error string is automatically\n * wrapped into a {@link ValidationError} using this node's key, its path, and the value that failed to\n * transform.\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? number\n * env.number('PORT').transform((port) =>\n * port > 0 ? Either.right(port) : Either.left('must be positive'),\n * ),\n * );\n * ```\n *\n * @example\n *\n * ```ts\n * const res = env.load(\n * // ^? number\n * env.number('PORT').transform((port, path) =>\n * port > 0\n * ? ValidationResult.success({ value: port, defaulted: [] })\n * : ValidationResult.fail({\n * errors: [{ path, key: 'PORT', message: 'must be positive', value: port }],\n * }),\n * ),\n * );\n * ```\n */\n override transform<U>(\n transform: (value: T, path: string) => ScalarValidationResultOrEither<U>,\n ): ScalarEnvNode<U> {\n return new ScalarEnvNode(this.key, (value, path) =>\n recoverScalarValidationResultOrEither(\n this.validateValue(value, path),\n path,\n this.key,\n value,\n ).flatMap((val) =>\n recoverScalarValidationResultOrEither(transform(val, path), path, this.key, value),\n ),\n );\n }\n}\n\nexport type ValidationResultOrEither<T> = ValidationResult<T> | Either<ValidationFailure, T>;\n\nfunction recoverValidationResultOrEither<T>(\n result: ValidationResultOrEither<T>,\n): ValidationResult<T> {\n if (result instanceof EitherBase) {\n return result.fold(\n (error) => ValidationResult.fail(error),\n (success) =>\n ValidationResult.success({\n value: success,\n defaulted: [],\n }),\n );\n }\n\n return result;\n}\n\nexport type ScalarValidationResultOrEither<T> = ValidationResultOrEither<T> | Either<string, T>;\n\nfunction recoverScalarValidationResultOrEither<T>(\n result: ScalarValidationResultOrEither<T>,\n path: string,\n key: string,\n value: unknown,\n): ValidationResult<T> {\n if (result instanceof EitherBase) {\n return result.fold(\n (error) =>\n ValidationResult.fail(\n typeof error === 'object'\n ? error\n : {\n errors: [\n {\n path,\n key,\n message: error,\n value,\n },\n ],\n },\n ),\n (success) =>\n ValidationResult.success({\n value: success,\n defaulted: [],\n }),\n );\n }\n\n return result;\n}\n\n/**\n * Describes the shape of a config schema: either a single EnvNode, or a nested object of them.\n */\nexport type EnvSpec = EnvNode<unknown> | { [key: string]: EnvSpec };\n\n/**\n * Infers the resulting value type produced by loading an EnvSpec.\n *\n * @example\n * ```ts\n * export function createModuleConfig() {\n * return {\n * url: env.string('URL'),\n * port: env.number('PORT').optional(),\n * };\n * }\n *\n * export type ModuleConfig = ParseEnv<ReturnType<typeof createModuleConfig>>;\n * // ^? { url: string; port?: number | undefined; }\n * ```\n */\nexport type ParseEnv<T extends EnvSpec> =\n T extends EnvNode<infer U>\n ? U\n : T extends Record<string, EnvSpec>\n ? { [K in keyof T]: ParseEnv<T[K]> }\n : never;\n\nexport type Pretty<T> = T extends infer U extends object ? { [K in keyof U]: Pretty<U[K]> } : T;\n"],"mappings":";;;;;;;;;AAeA,IAAa,UAAb,MAAa,QAAW;CAEtB,AAAS;CAKT,YACE,UAIA;EACA,KAAK,YAAY,WAAW,SAAS,gCAAgC,SAAS,WAAW,IAAI,CAAC;CAChG;;;;;;;;;;;;;;;;;;;CAoBA,UAAa,WAAgF;EAC3F,OAAO,IAAI,SAAS,WAAW,SAC7B,KAAK,SAAS,WAAW,IAAI,CAAC,CAAC,SAAS,UACtC,gCAAgC,UAAU,OAAO,IAAI,CAAC,CACxD,CACF;CACF;AACF;;;;;;AAOA,IAAa,gBAAb,MAAa,sBAAyB,QAAW;CAEpC;CACQ;CAFnB,YACE,AAAS,KACT,AAAiB,eAIjB;EACA,OAAO,WAAW,SAAS;GACzB,MAAM,QAAQ,UAAU,GAAG;GAE3B,OAAO,sCAAsC,cAAc,OAAO,IAAI,GAAG,MAAM,KAAK,KAAK;EAC3F,CAAC;EAVQ;EACQ;CAUnB;;;;;;;;;;;;;;;;;CAkBA,WAAyC;EACvC,OAAO,IAAI,cAA6B,KAAK,MAAM,OAAO,SACxD,UAAU,SACN,iBAAiB,QAAQ;GACvB,OAAO;GACP,WAAW,CACT;IACE;IACA,KAAK,KAAK;IACV,cAAc;GAChB,CACF;EACF,CAAC,IACD,KAAK,cAAc,OAAO,IAAI,CACpC;CACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCA,AAAS,UACP,WACkB;EAClB,OAAO,IAAI,cAAc,KAAK,MAAM,OAAO,SACzC,sCACE,KAAK,cAAc,OAAO,IAAI,GAC9B,MACA,KAAK,KACL,KACF,CAAC,CAAC,SAAS,QACT,sCAAsC,UAAU,KAAK,IAAI,GAAG,MAAM,KAAK,KAAK,KAAK,CACnF,CACF;CACF;AACF;AAIA,SAAS,gCACP,QACqB;CACrB,IAAI,kBAAkB,YACpB,OAAO,OAAO,MACX,UAAU,iBAAiB,KAAK,KAAK,IACrC,YACC,iBAAiB,QAAQ;EACvB,OAAO;EACP,WAAW,CAAC;CACd,CAAC,CACL;CAGF,OAAO;AACT;AAIA,SAAS,sCACP,QACA,MACA,KACA,OACqB;CACrB,IAAI,kBAAkB,YACpB,OAAO,OAAO,MACX,UACC,iBAAiB,KACf,OAAO,UAAU,WACb,QACA,EACE,QAAQ,CACN;EACE;EACA;EACA,SAAS;EACT;CACF,CACF,EACF,CACN,IACD,YACC,iBAAiB,QAAQ;EACvB,OAAO;EACP,WAAW,CAAC;CACd,CAAC,CACL;CAGF,OAAO;AACT"}
|
package/dist/env.d.mts
CHANGED
|
@@ -1,18 +1,216 @@
|
|
|
1
1
|
import { ValidationResult } from "./validation.mjs";
|
|
2
|
-
import { EnvNode, EnvSpec,
|
|
2
|
+
import { EnvNode, EnvSpec, ParseEnv, Pretty, ScalarEnvNode, ScalarValidationResultOrEither } from "./ast.mjs";
|
|
3
3
|
//#region src/env.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Type-safe configuration loader.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { env } from '@jeengbe/config';
|
|
11
|
+
*
|
|
12
|
+
* const config = env.load({
|
|
13
|
+
* port: env.number('PORT', 3000),
|
|
14
|
+
* debug: env.boolean('DEBUG', false),
|
|
15
|
+
* driver: env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {
|
|
16
|
+
* memory: {},
|
|
17
|
+
* redis: { url: env.string('REDIS_URL') },
|
|
18
|
+
* }),
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // -> {
|
|
22
|
+
* // port: number;
|
|
23
|
+
* // debug: boolean;
|
|
24
|
+
* // driver: { driver: 'memory' } | { driver: 'redis'; url: string };
|
|
25
|
+
* // }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Variables are read from `process.env` and trimmed before validation. Only a fully unset variable
|
|
29
|
+
* falls back to a default value or fails as required - an explicitly empty value is passed through to
|
|
30
|
+
* validation like any other input.
|
|
31
|
+
*/
|
|
4
32
|
interface Env {
|
|
33
|
+
/**
|
|
34
|
+
* Reads a string environment variable.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const res = env.load(env.string('API_KEY'));
|
|
40
|
+
* // ^? string
|
|
41
|
+
*
|
|
42
|
+
* // API_KEY=abc-test -> 'abc-test'
|
|
43
|
+
* // API_KEY=123 -> '123'
|
|
44
|
+
* // API_KEY= -> ''
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
5
47
|
string(key: string, defaultValue?: string): ScalarEnvNode<string>;
|
|
48
|
+
/**
|
|
49
|
+
* Reads a numeric environment variable. Must match `/^-?\d+(?:\.\d+)?$/`, i.e. `[-]digits[.digits]`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* const res = env.load(env.number('PORT'));
|
|
55
|
+
* // ^? number
|
|
56
|
+
*
|
|
57
|
+
* // PORT=3000 -> 3000
|
|
58
|
+
* // PORT=-1 -> -1
|
|
59
|
+
* // PORT=abc -> error
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
6
62
|
number(key: string, defaultValue?: number): ScalarEnvNode<number>;
|
|
63
|
+
/**
|
|
64
|
+
* Reads a boolean environment variable (must be 'true' or 'false').
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* const res = env.load(env.boolean('DEBUG'));
|
|
70
|
+
* // ^? boolean
|
|
71
|
+
*
|
|
72
|
+
* // DEBUG=true -> true
|
|
73
|
+
* // DEBUG=false -> false
|
|
74
|
+
* // DEBUG=abc -> error
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
7
77
|
boolean(key: string, defaultValue?: boolean): ScalarEnvNode<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Reads an environment variable constrained to one of the provided values.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* const res = env.load(env.enum('DRIVER', ['memory', 'redis']));
|
|
85
|
+
* // ^? 'memory' | 'redis'
|
|
86
|
+
*
|
|
87
|
+
* // DRIVER=memory -> 'memory'
|
|
88
|
+
* // DRIVER=abc -> error
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
8
91
|
enum<const T extends string>(key: string, values: readonly T[], defaultValue?: T): ScalarEnvNode<T>;
|
|
9
|
-
|
|
10
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Reads a comma-separated list environment variable, validating each item against the provided item type.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* const res = env.load(env.array(env.string('API_KEYS')));
|
|
99
|
+
* // ^? readonly string[]
|
|
100
|
+
*
|
|
101
|
+
* // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
|
|
102
|
+
* // API_KEYS= -> []
|
|
103
|
+
* // API_KEYS=abc,,ghi -> error
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
*
|
|
108
|
+
* ```ts
|
|
109
|
+
* const res = env.load(env.array(env.string('API_KEYS'), ['default1', 'default2']));
|
|
110
|
+
* // ^? readonly string[]
|
|
111
|
+
*
|
|
112
|
+
* // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
|
|
113
|
+
* // API_KEYS= -> []
|
|
114
|
+
* // (not set) -> ['default1', 'default2']
|
|
115
|
+
* // API_KEYS=abc,,ghi -> error
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
*
|
|
120
|
+
* ```ts
|
|
121
|
+
* const res = env.load(env.array(env.string('API_KEYS').optional()));
|
|
122
|
+
* // ^? readonly (string | undefined)[]
|
|
123
|
+
*
|
|
124
|
+
* // API_KEYS=abc,def,ghi -> ['abc', 'def', 'ghi']
|
|
125
|
+
* // API_KEYS= -> []
|
|
126
|
+
* // API_KEYS=abc,,ghi -> ['abc', undefined, 'ghi']
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
array<const T>(itemType: ScalarEnvNode<T>, defaultValue?: readonly T[]): ScalarEnvNode<readonly T[]>;
|
|
130
|
+
/**
|
|
131
|
+
* Reads an environment variable, validating and transforming it with the provided function.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
*
|
|
135
|
+
* ```ts
|
|
136
|
+
* const res = env.load(
|
|
137
|
+
* // ^? string
|
|
138
|
+
* env.scalar('TAG', (value, path) =>
|
|
139
|
+
* value.length === 3
|
|
140
|
+
* ? ValidationResult.success({ value, defaulted: [] })
|
|
141
|
+
* : ValidationResult.fail({
|
|
142
|
+
* errors: [{ path, key: 'TAG', message: 'must be 3 characters long', value }],
|
|
143
|
+
* }),
|
|
144
|
+
* ),
|
|
145
|
+
* );
|
|
146
|
+
*
|
|
147
|
+
* // TAG=abc -> 'abc'
|
|
148
|
+
* // TAG=abcd -> error
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
scalar<const T>(key: string, transform: (value: string, path: string) => ScalarValidationResultOrEither<T>, defaultValue?: T): ScalarEnvNode<T>;
|
|
152
|
+
/**
|
|
153
|
+
* Reads a discriminator environment variable and resolves the nested schema mapped to its value.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* const res = env.load(
|
|
159
|
+
* // ^? { driver: 'memory'; } | { driver: 'redis'; url: string; }
|
|
160
|
+
* env.discriminate('driver', env.enum('DRIVER', ['memory', 'redis']), {
|
|
161
|
+
* memory: {},
|
|
162
|
+
* redis: { url: env.string('REDIS_URL') },
|
|
163
|
+
* }),
|
|
164
|
+
* );
|
|
165
|
+
*
|
|
166
|
+
* // DRIVER=memory -> { driver: 'memory' }
|
|
167
|
+
* // DRIVER=redis, REDIS_URL=redis://localhost -> { driver: 'redis', url: 'redis://localhost' }
|
|
168
|
+
* // DRIVER=redis, REDIS_URL= -> { driver: 'redis', url: '' }
|
|
169
|
+
* // DRIVER=abc -> error
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
11
172
|
discriminate<K extends string, V extends string, M extends Partial<Record<V, Record<string, EnvSpec>>>>(discriminatorKey: K, discriminatorValueType: ScalarEnvNode<V>, mapping: M): EnvNode<DiscriminatorResult<K, V, M>>;
|
|
12
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Validates and loads the provided schema from `process.env`. Returns a `ValidationResult` that contains either the parsed values or a list of validation errors.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
*
|
|
178
|
+
* ```ts
|
|
179
|
+
* const result = env.parse({
|
|
180
|
+
* port: env.number('PORT'),
|
|
181
|
+
* debug: env.boolean('DEBUG'),
|
|
182
|
+
* });
|
|
183
|
+
*
|
|
184
|
+
* if (result.getLeft()) {
|
|
185
|
+
* console.error('Validation failed:', result.getLeft());
|
|
186
|
+
* } else {
|
|
187
|
+
* const config = result.get()!;
|
|
188
|
+
* // -> { port: number; debug: boolean; }
|
|
189
|
+
* }
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
parse<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): ValidationResult<Pretty<ParseEnv<S>>>;
|
|
193
|
+
/**
|
|
194
|
+
* Validates and loads the provided schema from `process.env`. Throws if any required variables are missing or invalid.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* const config = env.load({
|
|
200
|
+
* port: env.number('PORT', 3000),
|
|
201
|
+
* debug: env.boolean('DEBUG', false),
|
|
202
|
+
* }, console.log);
|
|
203
|
+
*
|
|
204
|
+
* // -> {
|
|
205
|
+
* // port: number;
|
|
206
|
+
* // debug: boolean;
|
|
207
|
+
* // }
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
load<S extends EnvSpec>(spec: S, env?: NodeJS.ProcessEnv): Pretty<ParseEnv<S>>;
|
|
13
211
|
}
|
|
14
212
|
declare const env: Env;
|
|
15
|
-
type DiscriminatorResult<K extends string, V extends string, M extends Partial<Record<V, Record<string, EnvSpec>>>> = Pretty<V extends unknown ? Record<K, V> &
|
|
213
|
+
type DiscriminatorResult<K extends string, V extends string, M extends Partial<Record<V, Record<string, EnvSpec>>>> = Pretty<V extends unknown ? Record<K, V> & ParseEnv<M[V] extends EnvSpec ? M[V] : {}> : never>;
|
|
16
214
|
//#endregion
|
|
17
215
|
export { Env, env };
|
|
18
216
|
//# sourceMappingURL=env.d.mts.map
|