@onebun/envs 0.1.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 ADDED
@@ -0,0 +1,206 @@
1
+ # @onebun/envs
2
+
3
+ Environment variables management package for OneBun framework with strict TypeScript support, validation, and Effect integration.
4
+
5
+ ## Features
6
+
7
+ - 🔒 **Type-safe**: Full TypeScript support with type inference
8
+ - 🛡️ **Validation**: Built-in validators and custom validation support
9
+ - 📊 **Effect Integration**: Uses Effect library for error handling
10
+ - 🔧 **Environment Loading**: Support for `.env` files and environment variables
11
+ - 🎭 **Sensitive Data**: Automatic masking of sensitive values in logs
12
+ - 🏗️ **Nested Configuration**: Support for nested configuration objects
13
+ - 📝 **Validation Helpers**: Pre-built validators for common use cases
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ bun add @onebun/envs
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { TypedEnv, Env } from '@onebun/envs';
25
+
26
+ // Define your configuration schema
27
+ const schema = {
28
+ app: {
29
+ port: Env.number({ default: 3000, validate: Env.port() }),
30
+ host: Env.string({ default: 'localhost' }),
31
+ env: Env.string({
32
+ default: 'development',
33
+ validate: Env.oneOf(['development', 'production', 'test'])
34
+ })
35
+ },
36
+ database: {
37
+ url: Env.string({
38
+ required: true,
39
+ validate: Env.url()
40
+ }),
41
+ password: Env.string({
42
+ sensitive: true,
43
+ required: true
44
+ })
45
+ }
46
+ };
47
+
48
+ // Create typed configuration
49
+ const config = await TypedEnv.createAsync(schema);
50
+
51
+ // Access values with full type safety
52
+ const port = config.get('app.port'); // number
53
+ const dbUrl = config.get('database.url'); // string
54
+
55
+ // Get safe config for logging (sensitive data masked)
56
+ console.log(config.getSafeConfig());
57
+ // Output: { app: { port: 3000, host: 'localhost', env: 'development' }, database: { url: 'postgres://...', password: '***' } }
58
+ ```
59
+
60
+ ## API Reference
61
+
62
+ ### Environment Variable Types
63
+
64
+ - `Env.string(options)` - String configuration
65
+ - `Env.number(options)` - Number configuration with range validation
66
+ - `Env.boolean(options)` - Boolean configuration
67
+ - `Env.array(options)` - Array configuration with length validation
68
+
69
+ ### Built-in Validators
70
+
71
+ - `Env.regex(pattern, message?)` - Regular expression validation
72
+ - `Env.oneOf(values, message?)` - Enum validation
73
+ - `Env.url(message?)` - URL validation
74
+ - `Env.email(message?)` - Email validation
75
+ - `Env.port(message?)` - Port number validation (1-65535)
76
+
77
+ ### Configuration Options
78
+
79
+ ```typescript
80
+ interface EnvVariableConfig<T> {
81
+ env?: string; // Custom environment variable name
82
+ description?: string; // Variable description
83
+ type: EnvValueType; // Variable type
84
+ default?: T; // Default value
85
+ required?: boolean; // Required field
86
+ sensitive?: boolean; // Sensitive field (masked in logs)
87
+ validate?: Function; // Custom validation function
88
+ separator?: string; // Array separator (default: ',')
89
+ }
90
+ ```
91
+
92
+ ## Testing
93
+
94
+ The package includes comprehensive unit tests with high coverage:
95
+
96
+ ```bash
97
+ # Run tests
98
+ bun test
99
+
100
+ # Run tests with coverage
101
+ bun run test:coverage
102
+ ```
103
+
104
+ ### Test Structure
105
+
106
+ - **Unit Tests**: 95+ tests covering all core functionality
107
+ - **Integration Tests**: Real-world scenarios and complex configurations
108
+ - **Error Handling**: Comprehensive error scenarios and edge cases
109
+ - **Performance Tests**: Large configuration handling and efficiency
110
+
111
+ ### Test Coverage
112
+
113
+ - ✅ **Types**: Error classes and type definitions
114
+ - ✅ **Parser**: String-to-type conversion and validation
115
+ - ✅ **Loader**: .env file loading and process.env handling
116
+ - ✅ **TypedEnv**: Configuration creation and management
117
+ - ✅ **Helpers**: All built-in validators and utilities
118
+ - ✅ **Integration**: End-to-end workflows and complex scenarios
119
+
120
+ ### Running Specific Tests
121
+
122
+ ```bash
123
+ # Run specific test file
124
+ bun test tests/parser.test.ts
125
+
126
+ # Run tests matching pattern
127
+ bun test --match "*validation*"
128
+
129
+ # Run tests in watch mode
130
+ bun test --watch
131
+ ```
132
+
133
+ ## Environment Variable Loading
134
+
135
+ The package supports multiple sources for environment variables:
136
+
137
+ 1. **Process Environment**: `process.env` variables
138
+ 2. **.env Files**: Support for `.env` file loading
139
+ 3. **Priority Control**: Configure which source takes precedence
140
+
141
+ ```typescript
142
+ const config = await TypedEnv.createAsync(schema, {
143
+ envFilePath: '.env.production',
144
+ envOverridesDotEnv: true, // process.env overrides .env file
145
+ loadDotEnv: true // enable .env file loading
146
+ });
147
+ ```
148
+
149
+ ## Advanced Usage
150
+
151
+ ### Custom Validation
152
+
153
+ ```typescript
154
+ const schema = {
155
+ apiKey: Env.string({
156
+ required: true,
157
+ sensitive: true,
158
+ validate: (value: string) => {
159
+ if (value.length < 32) {
160
+ return Effect.fail(new Error('API key too short'));
161
+ }
162
+ return Effect.succeed(value);
163
+ }
164
+ })
165
+ };
166
+ ```
167
+
168
+ ### Nested Configuration
169
+
170
+ ```typescript
171
+ const schema = {
172
+ server: {
173
+ http: {
174
+ port: Env.number({ default: 3000 }),
175
+ host: Env.string({ default: 'localhost' })
176
+ },
177
+ https: {
178
+ port: Env.number({ default: 3443 }),
179
+ cert: Env.string({ sensitive: true })
180
+ }
181
+ }
182
+ };
183
+
184
+ // Access nested values
185
+ const httpPort = config.get('server.http.port');
186
+ const httpsCert = config.get('server.https.cert');
187
+ ```
188
+
189
+ ## Error Handling
190
+
191
+ All validation errors are properly typed and provide detailed context:
192
+
193
+ ```typescript
194
+ try {
195
+ const config = await TypedEnv.createAsync(schema);
196
+ } catch (error) {
197
+ if (error instanceof EnvValidationError) {
198
+ console.log(`Validation failed for ${error.variable}: ${error.reason}`);
199
+ console.log(`Got value: ${error.value}`);
200
+ }
201
+ }
202
+ ```
203
+
204
+ ## License
205
+
206
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@onebun/envs",
3
+ "version": "0.1.0",
4
+ "description": "Environment variables management package for OneBun framework",
5
+ "license": "LGPL-3.0",
6
+ "author": "RemRyahirev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/RemRyahirev/onebun.git",
10
+ "directory": "packages/envs"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/RemRyahirev/onebun/issues"
14
+ },
15
+ "homepage": "https://github.com/RemRyahirev/onebun/tree/master/packages/envs#readme",
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "main": "src/index.ts",
25
+ "module": "src/index.ts",
26
+ "types": "src/index.ts",
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "test:coverage": "bun test --coverage",
30
+ "lint": "eslint --fix --config ../../eslint.config.js ./"
31
+ },
32
+ "dependencies": {
33
+ "effect": "^3.13.10"
34
+ },
35
+ "devDependencies": {
36
+ "bun-types": "1.2.2"
37
+ },
38
+ "engines": {
39
+ "bun": "1.2.2"
40
+ }
41
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { Effect } from 'effect';
2
+
3
+ import { EnvValidationError, type EnvVariableConfig } from './types';
4
+
5
+ /**
6
+ * Helpers for creating environment variable configurations
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/naming-convention
9
+ export const Env = {
10
+ /**
11
+ * Create configuration for string variable
12
+ */
13
+ string(
14
+ options: {
15
+ env?: string;
16
+ description?: string;
17
+ default?: string;
18
+ required?: boolean;
19
+ sensitive?: boolean;
20
+ separator?: string;
21
+ validate?: (value: string) => Effect.Effect<string, EnvValidationError>;
22
+ } = {},
23
+ ): EnvVariableConfig<string> {
24
+ return {
25
+ type: 'string',
26
+ separator: ',',
27
+ ...options,
28
+ };
29
+ },
30
+
31
+ /**
32
+ * Create configuration for number variable
33
+ */
34
+ number(
35
+ options: {
36
+ env?: string;
37
+ description?: string;
38
+ default?: number;
39
+ required?: boolean;
40
+ sensitive?: boolean;
41
+ min?: number;
42
+ max?: number;
43
+ validate?: (value: number) => Effect.Effect<number, EnvValidationError>;
44
+ } = {},
45
+ ): EnvVariableConfig<number> {
46
+ let validator = options.validate;
47
+
48
+ // Add range validation if specified
49
+ if (options.min !== undefined || options.max !== undefined) {
50
+ const rangeValidator = (value: number) => {
51
+ if (options.min !== undefined && value < options.min) {
52
+ return Effect.fail(new EnvValidationError('', value, `Value must be >= ${options.min}`));
53
+ }
54
+ if (options.max !== undefined && value > options.max) {
55
+ return Effect.fail(new EnvValidationError('', value, `Value must be <= ${options.max}`));
56
+ }
57
+
58
+ return Effect.succeed(value);
59
+ };
60
+
61
+ if (validator) {
62
+ const originalValidator = validator;
63
+ validator = (value: number) =>
64
+ rangeValidator(value).pipe(Effect.flatMap(originalValidator));
65
+ } else {
66
+ validator = rangeValidator;
67
+ }
68
+ }
69
+
70
+ return {
71
+ type: 'number',
72
+ description: options.description,
73
+ default: options.default,
74
+ required: options.required,
75
+ sensitive: options.sensitive,
76
+ env: options.env,
77
+ validate: validator,
78
+ };
79
+ },
80
+
81
+ /**
82
+ * Create configuration for boolean variable
83
+ */
84
+ boolean(
85
+ options: {
86
+ env?: string;
87
+ description?: string;
88
+ default?: boolean;
89
+ required?: boolean;
90
+ sensitive?: boolean;
91
+ validate?: (value: boolean) => Effect.Effect<boolean, EnvValidationError>;
92
+ } = {},
93
+ ): EnvVariableConfig<boolean> {
94
+ return {
95
+ type: 'boolean',
96
+ ...options,
97
+ };
98
+ },
99
+
100
+ /**
101
+ * Create configuration for string array variable
102
+ */
103
+ array(
104
+ options: {
105
+ env?: string;
106
+ description?: string;
107
+ default?: string[];
108
+ required?: boolean;
109
+ sensitive?: boolean;
110
+ separator?: string;
111
+ minLength?: number;
112
+ maxLength?: number;
113
+ validate?: (value: string[]) => Effect.Effect<string[], EnvValidationError>;
114
+ } = {},
115
+ ): EnvVariableConfig<string[]> {
116
+ let validator = options.validate;
117
+
118
+ // Add length validation if specified
119
+ if (options.minLength !== undefined || options.maxLength !== undefined) {
120
+ const lengthValidator = (value: string[]) => {
121
+ if (options.minLength !== undefined && value.length < options.minLength) {
122
+ return Effect.fail(
123
+ new EnvValidationError(
124
+ '',
125
+ value,
126
+ `Array must have at least ${options.minLength} items`,
127
+ ),
128
+ );
129
+ }
130
+ if (options.maxLength !== undefined && value.length > options.maxLength) {
131
+ return Effect.fail(
132
+ new EnvValidationError('', value, `Array must have at most ${options.maxLength} items`),
133
+ );
134
+ }
135
+
136
+ return Effect.succeed(value);
137
+ };
138
+
139
+ if (validator) {
140
+ const originalValidator = validator;
141
+ validator = (value: string[]) =>
142
+ lengthValidator(value).pipe(Effect.flatMap(originalValidator));
143
+ } else {
144
+ validator = lengthValidator;
145
+ }
146
+ }
147
+
148
+ return {
149
+ type: 'array',
150
+ description: options.description,
151
+ default: options.default,
152
+ required: options.required,
153
+ sensitive: options.sensitive,
154
+ env: options.env,
155
+ separator: options.separator || ',',
156
+ validate: validator,
157
+ };
158
+ },
159
+
160
+ /**
161
+ * Create validator for string with regular expression
162
+ */
163
+ regex(
164
+ pattern: RegExp,
165
+ errorMessage?: string,
166
+ ): (value: string) => Effect.Effect<string, EnvValidationError> {
167
+ return (value: string) => {
168
+ if (!pattern.test(value)) {
169
+ return Effect.fail(
170
+ new EnvValidationError('', value, errorMessage || `Value must match pattern ${pattern}`),
171
+ );
172
+ }
173
+
174
+ return Effect.succeed(value);
175
+ };
176
+ },
177
+
178
+ /**
179
+ * Create validator for string from allowed values list
180
+ */
181
+ oneOf<T extends string>(
182
+ allowedValues: readonly T[],
183
+ errorMessage?: string,
184
+ ): (value: string) => Effect.Effect<T, EnvValidationError> {
185
+ return (value: string) => {
186
+ if (!allowedValues.includes(value as T)) {
187
+ return Effect.fail(
188
+ new EnvValidationError(
189
+ '',
190
+ value,
191
+ errorMessage || `Value must be one of: ${allowedValues.join(', ')}`,
192
+ ),
193
+ );
194
+ }
195
+
196
+ return Effect.succeed(value as T);
197
+ };
198
+ },
199
+
200
+ /**
201
+ * Create validator for URL
202
+ */
203
+ url(errorMessage?: string): (value: string) => Effect.Effect<string, EnvValidationError> {
204
+ return (value: string) => {
205
+ try {
206
+ const url = new URL(value);
207
+
208
+ // Reject potentially dangerous schemes
209
+ const dangerousSchemes = ['javascript', 'data', 'vbscript'];
210
+ if (dangerousSchemes.includes(url.protocol.slice(0, -1))) {
211
+ throw new Error('Dangerous URL scheme');
212
+ }
213
+
214
+ return Effect.succeed(value);
215
+ } catch {
216
+ return Effect.fail(
217
+ new EnvValidationError('', value, errorMessage || 'Value must be a valid URL'),
218
+ );
219
+ }
220
+ };
221
+ },
222
+
223
+ /**
224
+ * Create validator for email
225
+ */
226
+ email(errorMessage?: string): (value: string) => Effect.Effect<string, EnvValidationError> {
227
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
228
+
229
+ return (value: string) => {
230
+ if (!emailRegex.test(value)) {
231
+ return Effect.fail(
232
+ new EnvValidationError('', value, errorMessage || 'Value must be a valid email address'),
233
+ );
234
+ }
235
+
236
+ return Effect.succeed(value);
237
+ };
238
+ },
239
+
240
+ /**
241
+ * Create validator for port number
242
+ */
243
+ port(errorMessage?: string): (value: number) => Effect.Effect<number, EnvValidationError> {
244
+ return (value: number) => {
245
+ // eslint-disable-next-line no-magic-numbers
246
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
247
+ return Effect.fail(
248
+ new EnvValidationError(
249
+ '',
250
+ value,
251
+ errorMessage || 'Port must be an integer between 1 and 65535',
252
+ ),
253
+ );
254
+ }
255
+
256
+ return Effect.succeed(value);
257
+ };
258
+ },
259
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Re-export Effect for convenience
2
+ export { Effect } from 'effect';
3
+ export * from './helpers';
4
+ export * from './loader';
5
+ export * from './parser';
6
+ export * from './typed-env';
7
+ export * from './types';
package/src/loader.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { Effect } from 'effect';
2
+
3
+ import { EnvLoadError, type EnvLoadOptions } from './types';
4
+
5
+ /**
6
+ * Environment variables loader
7
+ */
8
+ export class EnvLoader {
9
+ /**
10
+ * Load environment variables from various sources.
11
+ * Priority (highest to lowest):
12
+ * 1. valueOverrides (if provided)
13
+ * 2. process.env (if envOverridesDotEnv = true)
14
+ * 3. .env file
15
+ * 4. process.env (if envOverridesDotEnv = false)
16
+ */
17
+ static load(options: EnvLoadOptions = {}): Effect.Effect<Record<string, string>, EnvLoadError> {
18
+ const {
19
+ envFilePath = '.env',
20
+ loadDotEnv = true,
21
+ envOverridesDotEnv = true,
22
+ valueOverrides,
23
+ } = options;
24
+
25
+ const loadDotEnvVars = loadDotEnv
26
+ ? EnvLoader.loadDotEnvFile(envFilePath)
27
+ : Effect.succeed({} as Record<string, string>);
28
+
29
+ const processEnvVars = EnvLoader.loadProcessEnv();
30
+
31
+ return loadDotEnvVars.pipe(
32
+ Effect.map((dotEnvVars) => {
33
+ let result: Record<string, string>;
34
+
35
+ if (envOverridesDotEnv) {
36
+ // Process environment variables have priority over .env
37
+ result = { ...dotEnvVars, ...processEnvVars };
38
+ } else {
39
+ // .env file has priority over process.env
40
+ result = { ...processEnvVars, ...dotEnvVars };
41
+ }
42
+
43
+ // Apply valueOverrides with highest priority
44
+ if (valueOverrides) {
45
+ for (const [key, value] of Object.entries(valueOverrides)) {
46
+ result[key] = String(value);
47
+ }
48
+ }
49
+
50
+ return result;
51
+ }),
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Load variables from .env file
57
+ */
58
+ private static loadDotEnvFile(
59
+ filePath: string,
60
+ ): Effect.Effect<Record<string, string>, EnvLoadError> {
61
+ return Effect.tryPromise({
62
+ async try() {
63
+ const file = Bun.file(filePath);
64
+ const exists = await file.exists();
65
+
66
+ if (!exists) {
67
+ return {}; // File not found - not an error, just return empty object
68
+ }
69
+
70
+ const content = await file.text();
71
+
72
+ return EnvLoader.parseDotEnvContent(content);
73
+ },
74
+ catch: (error) =>
75
+ new EnvLoadError(
76
+ filePath,
77
+ `Failed to read .env file: ${error instanceof Error ? error.message : String(error)}`,
78
+ ),
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Parse .env file content
84
+ */
85
+ private static parseDotEnvContent(content: string): Record<string, string> {
86
+ const variables: Record<string, string> = {};
87
+ const lines = content.split('\n');
88
+
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const line = lines[i].trim();
91
+
92
+ // Skip empty lines and comments
93
+ if (!line || line.startsWith('#')) {
94
+ continue;
95
+ }
96
+
97
+ // Find equals sign
98
+ const equalIndex = line.indexOf('=');
99
+ if (equalIndex === -1) {
100
+ continue; // Skip lines without equals sign
101
+ }
102
+
103
+ const key = line.slice(0, equalIndex).trim();
104
+ let value = line.slice(equalIndex + 1).trim();
105
+
106
+ // Remove quotes if present
107
+ if (
108
+ (value.startsWith('"') && value.endsWith('"')) ||
109
+ (value.startsWith("'") && value.endsWith("'"))
110
+ ) {
111
+ value = value.slice(1, -1);
112
+ }
113
+
114
+ // Process escape sequences
115
+ value = value
116
+ .replace(/\\n/g, '\n')
117
+ .replace(/\\r/g, '\r')
118
+ .replace(/\\t/g, '\t')
119
+ .replace(/\\\\/g, '\\')
120
+ .replace(/\\"/g, '"')
121
+ .replace(/\\'/g, "'");
122
+
123
+ variables[key] = value;
124
+ }
125
+
126
+ return variables;
127
+ }
128
+
129
+ /**
130
+ * Load variables from process.env
131
+ */
132
+ private static loadProcessEnv(): Record<string, string> {
133
+ const variables: Record<string, string> = {};
134
+
135
+ for (const [key, value] of Object.entries(process.env)) {
136
+ if (value !== undefined) {
137
+ variables[key] = value;
138
+ }
139
+ }
140
+
141
+ return variables;
142
+ }
143
+
144
+ /**
145
+ * Check if .env file exists
146
+ */
147
+ static checkDotEnvExists(filePath: string = '.env'): Effect.Effect<boolean, never> {
148
+ return Effect.promise(() => Bun.file(filePath).exists());
149
+ }
150
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { Effect } from 'effect';
2
+
3
+ import {
4
+ type EnvLoadOptions,
5
+ EnvValidationError,
6
+ type EnvValueType,
7
+ type EnvVariableConfig,
8
+ } from './types';
9
+
10
+ /**
11
+ * Environment variable parser
12
+ */
13
+ export class EnvParser {
14
+ /**
15
+ * Parse string value according to configuration
16
+ */
17
+ static parse<T>(
18
+ variable: string,
19
+ value: string | undefined,
20
+ config: EnvVariableConfig<T>,
21
+ options: EnvLoadOptions = {},
22
+ ): Effect.Effect<T, EnvValidationError> {
23
+ const resolveValue = Effect.sync(() => {
24
+ // If value is not set
25
+ if (value === undefined) {
26
+ if (config.default !== undefined) {
27
+ return config.default;
28
+ }
29
+ if (config.required) {
30
+ throw new EnvValidationError(variable, value, 'Required variable is not set');
31
+ }
32
+
33
+ return EnvParser.getDefaultForTypeSync(config.type);
34
+ }
35
+
36
+ return value;
37
+ });
38
+
39
+ const parseValue = (resolvedValue: unknown) => {
40
+ if (typeof resolvedValue === 'string') {
41
+ const separator = config.separator || options.defaultArraySeparator || ',';
42
+
43
+ return EnvParser.parseByType(variable, resolvedValue, config.type, separator);
44
+ }
45
+
46
+ return Effect.succeed(resolvedValue);
47
+ };
48
+
49
+ const validateParsed = (parsed: unknown) =>
50
+ EnvParser.validateValue(variable, parsed as T, config);
51
+
52
+ return resolveValue.pipe(Effect.flatMap(parseValue), Effect.flatMap(validateParsed));
53
+ }
54
+
55
+ /**
56
+ * Parse value by type
57
+ */
58
+ private static parseByType(
59
+ variable: string,
60
+ value: string,
61
+ type: EnvValueType,
62
+ separator = ',',
63
+ ): Effect.Effect<unknown, EnvValidationError> {
64
+ return Effect.try({
65
+ try() {
66
+ switch (type) {
67
+ case 'string':
68
+ return value;
69
+
70
+ case 'number': {
71
+ // Empty string should be rejected for numbers
72
+ if (value.trim() === '') {
73
+ throw new Error(`"${value}" is not a valid number`);
74
+ }
75
+
76
+ const num = Number(value);
77
+ if (isNaN(num)) {
78
+ throw new Error(`"${value}" is not a valid number`);
79
+ }
80
+
81
+ return num;
82
+ }
83
+
84
+ case 'boolean': {
85
+ const lower = value.toLowerCase();
86
+ if (['true', '1', 'yes', 'on'].includes(lower)) {
87
+ return true;
88
+ }
89
+ if (['false', '0', 'no', 'off'].includes(lower)) {
90
+ return false;
91
+ }
92
+ throw new Error(`"${value}" is not a valid boolean`);
93
+ }
94
+
95
+ case 'array': {
96
+ if (value.trim() === '') {
97
+ return [];
98
+ }
99
+
100
+ return value.split(separator).map((item) => item.trim());
101
+ }
102
+
103
+ default:
104
+ throw new Error(`Unknown type: ${type}`);
105
+ }
106
+ },
107
+ catch: (error) =>
108
+ new EnvValidationError(
109
+ variable,
110
+ value,
111
+ error instanceof Error ? error.message : String(error),
112
+ ),
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Validate value
118
+ */
119
+ private static validateValue<T>(
120
+ variable: string,
121
+ value: T,
122
+ config: EnvVariableConfig<T>,
123
+ ): Effect.Effect<T, EnvValidationError> {
124
+ if (config.validate) {
125
+ return config.validate(value);
126
+ }
127
+
128
+ return Effect.succeed(value);
129
+ }
130
+
131
+ /**
132
+ * Get default value for type (sync)
133
+ */
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ private static getDefaultForTypeSync(type: EnvValueType): any {
136
+ switch (type) {
137
+ case 'string':
138
+ return '';
139
+ case 'number':
140
+ return 0;
141
+ case 'boolean':
142
+ return false;
143
+ case 'array':
144
+ return [];
145
+ default:
146
+ throw new EnvValidationError('unknown', undefined, `Unknown type: ${type}`);
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,323 @@
1
+ import { Effect } from 'effect';
2
+
3
+ import { EnvLoader } from './loader';
4
+ import { EnvParser } from './parser';
5
+ import {
6
+ type EnvLoadOptions,
7
+ type EnvSchema,
8
+ EnvValidationError,
9
+ type EnvVariableConfig,
10
+ } from './types';
11
+
12
+ /**
13
+ * Utility types for automatic type inference
14
+ */
15
+ type DeepValue<T, Path extends string> = Path extends keyof T
16
+ ? T[Path]
17
+ : Path extends `${infer K}.${infer Rest}`
18
+ ? K extends keyof T
19
+ ? T[K] extends object
20
+ ? DeepValue<T[K], Rest>
21
+ : never
22
+ : never
23
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ any; // Fallback to any for complex paths
25
+
26
+ type DeepPaths<T> = T extends object
27
+ ? {
28
+ [K in keyof T]: K extends string
29
+ ? T[K] extends object
30
+ ? K | `${K}.${DeepPaths<T[K]>}`
31
+ : K
32
+ : never;
33
+ }[keyof T]
34
+ : never;
35
+
36
+ /**
37
+ * Sensitive value wrapper that sanitizes toString() output
38
+ */
39
+ class SensitiveValue<T> {
40
+ constructor(private readonly _value: T) {}
41
+
42
+ get value(): T {
43
+ return this._value;
44
+ }
45
+
46
+ toString(): string {
47
+ return '***';
48
+ }
49
+
50
+ toJSON(): string {
51
+ return '***';
52
+ }
53
+
54
+ valueOf(): T {
55
+ return this._value;
56
+ }
57
+
58
+ [Symbol.toPrimitive](hint: string): string | number | T {
59
+ if (hint === 'string') {
60
+ return '***';
61
+ }
62
+ if (hint === 'number' && typeof this._value === 'number') {
63
+ return this._value;
64
+ }
65
+
66
+ return this._value;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Configuration proxy that intercepts access and provides type safety
72
+ */
73
+ class ConfigProxy<T> {
74
+ private _isInitialized = false;
75
+ private _values: T | null = null;
76
+ private _sensitiveFields: Set<string> = new Set();
77
+
78
+ constructor(
79
+ private readonly _schema: EnvSchema<T>,
80
+ private readonly _options: EnvLoadOptions = {},
81
+ ) {
82
+ this.extractSensitiveFields(this._schema, '');
83
+ }
84
+
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ private extractSensitiveFields(schema: any, prefix = ''): void {
87
+ for (const [key, config] of Object.entries(schema)) {
88
+ const fullPath = prefix ? `${prefix}.${key}` : key;
89
+
90
+ if (this.isEnvVariableConfig(config)) {
91
+ if ((config as EnvVariableConfig).sensitive) {
92
+ this._sensitiveFields.add(fullPath);
93
+ }
94
+ } else {
95
+ this.extractSensitiveFields(config, fullPath);
96
+ }
97
+ }
98
+ }
99
+
100
+ private isEnvVariableConfig(config: unknown): boolean {
101
+ return Boolean(config) && typeof config === 'object' && 'type' in config!;
102
+ }
103
+
104
+ private async ensureInitialized(): Promise<void> {
105
+ if (this._isInitialized) {
106
+ return;
107
+ }
108
+
109
+ const rawVariables = await Effect.runPromise(EnvLoader.load(this._options));
110
+ this._values = this.parseNestedSchema(this._schema, rawVariables, '') as T;
111
+ this._isInitialized = true;
112
+ }
113
+
114
+
115
+ private parseNestedSchema(
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ schema: any,
118
+ rawVariables: Record<string, string>,
119
+ prefix: string,
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ ): any {
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ const result: any = {};
124
+
125
+ for (const [key, config] of Object.entries(schema)) {
126
+ const fullPath = prefix ? `${prefix}.${key}` : key;
127
+
128
+ if (this.isEnvVariableConfig(config)) {
129
+ const envConfig = config as EnvVariableConfig;
130
+ const envVar = envConfig.env || this.pathToEnvVar(fullPath);
131
+ const rawValue = rawVariables[envVar];
132
+
133
+ try {
134
+ const parsed = Effect.runSync(
135
+ EnvParser.parse(
136
+ envVar,
137
+ rawValue,
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
139
+ envConfig as any,
140
+ this._options,
141
+ ),
142
+ );
143
+ result[key] = parsed;
144
+ } catch (error) {
145
+ if (error instanceof EnvValidationError) {
146
+ throw error;
147
+ }
148
+ throw new EnvValidationError(envVar, rawValue, String(error));
149
+ }
150
+ } else {
151
+ result[key] = this.parseNestedSchema(config, rawVariables, fullPath);
152
+ }
153
+ }
154
+
155
+ return result;
156
+ }
157
+
158
+ private pathToEnvVar(path: string): string {
159
+ return path.toUpperCase().replace(/\./g, '_');
160
+ }
161
+
162
+ private getValueByPath(obj: Record<string, unknown>, path: string): unknown {
163
+ const keys = path.split('.');
164
+ let current: Record<string, unknown> | unknown = obj;
165
+
166
+ for (const key of keys) {
167
+ if (current && typeof current === 'object' && key in current) {
168
+ current = current[key as keyof typeof current];
169
+ } else {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ return current;
175
+ }
176
+
177
+ /**
178
+ * Synchronous get method with automatic type inference and sensitive data handling
179
+ */
180
+ get<P extends DeepPaths<T>>(path: P): DeepValue<T, P>;
181
+ get<P extends keyof T>(path: P): T[P];
182
+
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ get(path: string): any;
185
+ get(path: string): unknown {
186
+ if (!this._isInitialized || !this._values) {
187
+ throw new Error(
188
+ 'Configuration not initialized. Call TypedEnv.create() or ensure initialization is complete.',
189
+ );
190
+ }
191
+
192
+ const value = this.getValueByPath(this._values, path);
193
+
194
+ // Wrap sensitive values
195
+ if (this._sensitiveFields.has(path)) {
196
+ return new SensitiveValue(value);
197
+ }
198
+
199
+ return value;
200
+ }
201
+
202
+ /**
203
+ * Get the entire configuration object
204
+ */
205
+ get values(): T {
206
+ if (!this._isInitialized || !this._values) {
207
+ throw new Error(
208
+ 'Configuration not initialized. Call TypedEnv.create() or ensure initialization is complete.',
209
+ );
210
+ }
211
+
212
+ return this._values;
213
+ }
214
+
215
+ /**
216
+ * Initialize the configuration (async)
217
+ */
218
+ async initialize(): Promise<void> {
219
+ await this.ensureInitialized();
220
+ }
221
+
222
+ /**
223
+ * Check if configuration is initialized
224
+ */
225
+ get isInitialized(): boolean {
226
+ return this._isInitialized;
227
+ }
228
+
229
+ /**
230
+ * Get safe configuration for logging (sensitive data masked)
231
+ */
232
+ getSafeConfig(): T {
233
+ if (!this._isInitialized || !this._values) {
234
+ throw new Error('Configuration not initialized.');
235
+ }
236
+
237
+ return this.applySensitiveMask(this._values, '') as T;
238
+ }
239
+
240
+ private applySensitiveMask<U = unknown>(obj: U, prefix = ''): U {
241
+ if (obj === null || obj === undefined) {
242
+ return obj;
243
+ }
244
+
245
+ if (Array.isArray(obj)) {
246
+ return obj.map((item) =>
247
+ typeof item === 'object' ? this.applySensitiveMask<U>(item, prefix) : item,
248
+ ) as U;
249
+ }
250
+
251
+ if (typeof obj === 'object') {
252
+ const result: Record<string, unknown> = {};
253
+
254
+ for (const [key, value] of Object.entries(obj)) {
255
+ const fullPath = prefix ? `${prefix}.${key}` : key;
256
+
257
+ if (this._sensitiveFields.has(fullPath)) {
258
+ result[key] = '***';
259
+ } else if (value && typeof value === 'object') {
260
+ result[key] = this.applySensitiveMask<U>(value, fullPath);
261
+ } else {
262
+ result[key] = value;
263
+ }
264
+ }
265
+
266
+ return result as U;
267
+ }
268
+
269
+ return obj;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Static factory for creating typed environment configurations
275
+ */
276
+ export class TypedEnv {
277
+ private static instances = new Map<string, ConfigProxy<unknown>>();
278
+
279
+ /**
280
+ * Create or get existing typed environment configuration
281
+ */
282
+ static create<T>(
283
+ schema: EnvSchema<T>,
284
+ options: EnvLoadOptions = {},
285
+ key = 'default',
286
+ ): ConfigProxy<T> {
287
+ if (!TypedEnv.instances.has(key)) {
288
+ const proxy = new ConfigProxy(schema, options);
289
+ TypedEnv.instances.set(key, proxy);
290
+
291
+ // Auto-initialize
292
+ proxy.initialize();
293
+ }
294
+
295
+ return TypedEnv.instances.get(key) as ConfigProxy<T>;
296
+ }
297
+
298
+ /**
299
+ * Create typed environment configuration with immediate initialization
300
+ */
301
+ static async createAsync<T>(
302
+ schema: EnvSchema<T>,
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ options: any = {},
305
+ key = 'default',
306
+ ): Promise<ConfigProxy<T>> {
307
+ const proxy = TypedEnv.create(schema, options, key);
308
+ await proxy.initialize();
309
+
310
+ return proxy;
311
+ }
312
+
313
+ /**
314
+ * Clear all instances (useful for testing)
315
+ */
316
+ static clear(): void {
317
+ TypedEnv.instances.clear();
318
+ }
319
+ }
320
+
321
+ // Export type helpers for external use
322
+ export type { DeepPaths, DeepValue, SensitiveValue };
323
+ export { ConfigProxy };
package/src/types.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { Effect } from 'effect';
2
+
3
+ /**
4
+ * Environment variable value types
5
+ */
6
+ export type EnvValueType = 'string' | 'number' | 'boolean' | 'array';
7
+
8
+ /**
9
+ * Configuration for environment variable
10
+ */
11
+ export interface EnvVariableConfig<T = unknown> {
12
+ /** Environment variable name (if different from schema key) */
13
+ env?: string;
14
+ /** Variable description */
15
+ description?: string;
16
+ /** Variable type */
17
+ type: EnvValueType;
18
+ /** Default value */
19
+ default?: T;
20
+ /** Required field - will throw error if not provided */
21
+ required?: boolean;
22
+ /** Sensitive field - will be masked in logs */
23
+ sensitive?: boolean;
24
+ /** Validation function */
25
+ validate?: (value: T) => Effect.Effect<T, EnvValidationError>;
26
+ /** Separator for arrays (default: ',') */
27
+ separator?: string;
28
+ }
29
+
30
+ /**
31
+ * Environment variables schema supporting nested objects
32
+ */
33
+ export type EnvSchema<T> = {
34
+ [K in keyof T]: T[K] extends string | number | boolean | string[] | number[] | boolean[]
35
+ ? EnvVariableConfig<T[K]>
36
+ : T[K] extends Record<string, unknown>
37
+ ? EnvSchema<T[K]>
38
+ : EnvVariableConfig<T[K]>;
39
+ };
40
+
41
+ /**
42
+ * Format value for error messages
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ function formatValue(value: any): string {
46
+ if (value === undefined) {
47
+ return 'undefined';
48
+ }
49
+ if (value === null) {
50
+ return 'null';
51
+ }
52
+ if (typeof value === 'string') {
53
+ return `"${value}"`;
54
+ }
55
+ if (typeof value === 'object') {
56
+ try {
57
+ return JSON.stringify(value);
58
+ } catch {
59
+ return '[object Object]';
60
+ }
61
+ }
62
+
63
+ return String(value);
64
+ }
65
+
66
+ /**
67
+ * Environment variable validation error
68
+ */
69
+ export class EnvValidationError extends Error {
70
+ constructor(
71
+ public readonly variable: string,
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
73
+ public readonly value: any,
74
+ public readonly reason: string,
75
+ ) {
76
+ super(
77
+ `Environment variable validation failed for "${variable}": ${reason}. Got: ${formatValue(value)}`,
78
+ );
79
+ this.name = 'EnvValidationError';
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Environment variable loading error
85
+ */
86
+ export class EnvLoadError extends Error {
87
+ constructor(
88
+ public readonly variable: string,
89
+ public readonly reason: string,
90
+ ) {
91
+ super(`Failed to load environment variable "${variable}": ${reason}`);
92
+ this.name = 'EnvLoadError';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Environment loading options
98
+ */
99
+ export interface EnvLoadOptions {
100
+ /** Path to .env file (default: '.env') */
101
+ envFilePath?: string;
102
+ /** Whether to load .env file (default: true) */
103
+ loadDotEnv?: boolean;
104
+ /** Environment variables override .env file (default: true) */
105
+ envOverridesDotEnv?: boolean;
106
+ /** Strict mode - only load variables defined in schema (default: false) */
107
+ strict?: boolean;
108
+ /** Default separator for arrays (default: ',') */
109
+ defaultArraySeparator?: string;
110
+ /**
111
+ * Override values that take precedence over both process.env and .env file.
112
+ * Useful for multi-service setups where each service needs different values.
113
+ */
114
+ valueOverrides?: Record<string, string | number | boolean>;
115
+ }