├── .gitignore ├── .idea ├── .gitignore ├── classenv.iml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── .prettierrc ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── env.ts └── index.ts ├── test └── env.spec.ts ├── tsconfig.build.json ├── tsconfig.checks.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/classenv.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 21 | 22 | 29 | 30 | 37 | 38 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript environment variable decorator 2 | 3 | A perfect TypeScript environment variables library. 4 | 5 | - Strongly-typed declarative class containing your environment data 6 | - Supports both static and instance properties 7 | - Type-casting using TypeScript metadata reflection 8 | - Auto UPPER_SNAKE_CASE conversion 9 | - Converts environment values "FALSE", "false", "0" to false for boolean types 10 | - Throws runtime error if variable doesn't exist 11 | - Supports default values 12 | - Makes decorated properties read-only in runtime 13 | - ❤️ You will like it 14 | 15 | ## 💼 Use cases 16 | 17 | ### 🪞 Type-casting Using TypeScript metadata reflection 18 | 19 | Just specify class field type and `classenv` will cast the environment variable string value to the value of your field type. 20 | Only `string`, `number`, and `boolean` is supported. 21 | 22 | ```ts 23 | process.env['PORT'] = '3000'; 24 | 25 | class ServerSettings { 26 | @Env('PORT') 27 | portNumber!: number; // 3000 28 | @Env('PORT') 29 | portString!: string; // "3000" 30 | @Env('PORT') // Why not?! 31 | portBoolean!: boolean; // true 32 | } 33 | ``` 34 | 35 | ### 🐍 Auto UPPER_SNAKE_CASE from camelCase conversion 36 | 37 | No need to manually specify the environment variable name 38 | 39 | ```ts 40 | process.env['POSTGRES_URL'] = 'postgres://127.0.0.1:5432'; 41 | 42 | class PostgresAdapter { 43 | // Field name will be auto-converted to POSTGRES_URL for checking the process.env 44 | @Env() 45 | postgresUrl!: string; // "postgres://127.0.0.1:5432" 46 | } 47 | ``` 48 | 49 | ### 🫙 Use default value in case of environment variable absence 50 | 51 | ```ts 52 | class ServerSettings { 53 | @Env() 54 | port: number = 3000; // 3000 55 | } 56 | ``` 57 | 58 | ### 🚔 Throw runtime error if no value provided 59 | 60 | One could say `"It's a bad practice to throw runtime error"`, and it's a right assertion, but not in this case. 61 | Most of the time your application can't work without all the environment variables. 62 | You don't want to run application in an indefinite state and then debug these strange things. 63 | So `classenv` will throw runtime error and your application should shut down with an informative message of what's going wrong. 64 | 65 | ```ts 66 | class PostgresAdapter { 67 | @Env() 68 | // Will throw a runtime error, because your app can't work without DB connection 69 | postgresUrl!: string; 70 | } 71 | ``` 72 | 73 | But in case the environment variable is not required – you can just assign a default value for the field, and it will not throw. 74 | 75 | ```ts 76 | class PostgresAdapter { 77 | @Env() 78 | postgresUrl: string = 'postgres://127.0.0.1:5432'; // Everything is ok here 79 | } 80 | ``` 81 | 82 | ### 🔘 Pick one of the names from array 83 | 84 | ```ts 85 | process.env['POSTGRES_URL'] = 'postgres://127.0.0.1:5432'; 86 | 87 | class PostgresAdapter { 88 | @Env(['POSTGRESQL_URI', 'PG_URL', 'POSTGRES_URL']) 89 | url!: string; // "postgres://127.0.0.1:5432" 90 | } 91 | ``` 92 | 93 | ### ✨ `static` field also supported 94 | 95 | ```ts 96 | process.env['PORT'] = '3000'; 97 | 98 | class ServerSettings { 99 | @Env() 100 | static port: number; // "3000" 101 | } 102 | ``` 103 | 104 | ### 1️⃣ Boolean type casting 0️⃣ 105 | 106 | If value is `0` of `false` in any case (`FaLsE` also included, since it's `.toLowerCase()`'d under the hood) – it becomes `false`. 107 | Otherwise - `true` 108 | 109 | ```ts 110 | process.env['FALSE'] = 'false'; 111 | process.env['ZERO'] = '0'; 112 | process.env['TRUE'] = 'true'; 113 | process.env['ANYTHING'] = 'Jast a random string'; 114 | 115 | class Common { 116 | @Env() 117 | static FALSE!: boolean; // false 118 | @Env() 119 | static zero!: boolean; // false 120 | @Env() 121 | static TRUE!: boolean; // true 122 | @Env() 123 | static anything!: boolean; // true 124 | } 125 | ``` 126 | 127 | ### 🛑 `@Env()` decorated properties are read-only in runtime 128 | 129 | Environment is something established from outside, so you definitely should not modify it in your application. 130 | 131 | 132 | ```ts 133 | process.env['PORT'] = '3000'; 134 | 135 | class ServerSettings { 136 | @Env() 137 | static port!: number; 138 | } 139 | 140 | // TypeError: Cannot assign to read only property 'port' of function 'class ServerSettings{}' 141 | 142 | ServerSettings.port = 5000; 143 | ``` 144 | 145 | ## ❗Dependencies❗ 146 | 147 | It is important, `classenv` can not work without it. 148 | 149 | ### reflect-metadata 150 | 151 | ``` 152 | npm i reflect-metadata 153 | ``` 154 | 155 | And then import it somewhere close to your entry point (`index.ts`/`main.ts`/etc...). 156 | Should be imported before any of your environment classes. 157 | 158 | ```typescript 159 | import 'reflect-metadata'; 160 | ``` 161 | 162 | ### tsconfig.json 163 | 164 | These settings should be enabled 165 | 166 | ``` 167 | "emitDecoratorMetadata": true, 168 | "experimentalDecorators": true, 169 | ``` 170 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classenv", 3 | "version": "1.4.1", 4 | "description": "Describe your environment variables contract with TypeScript class decorator", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc -p tsconfig.build.json", 12 | "test": "npx jest", 13 | "format": "prettier --write \"src/**/*.ts\" --loglevel warn", 14 | "prebuild": "rm -rf ./dist", 15 | "prepublishOnly": "npm run build" 16 | }, 17 | "author": "bowzee", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/dilame/classenv" 21 | }, 22 | "license": "ISC", 23 | "devDependencies": { 24 | "@types/jest": "^25.1.4", 25 | "@types/node": "^17.0.31", 26 | "jest": "^25.1.0", 27 | "prettier": "^2.7.1", 28 | "ts-jest": "^25.2.1", 29 | "typescript": "^4.8.4" 30 | }, 31 | "peerDependencies": { 32 | "reflect-metadata": "^0.1.13" 33 | }, 34 | "keywords": [ 35 | "typescript", 36 | "class", 37 | "environment", 38 | "env", 39 | "variables", 40 | "decorator", 41 | "dotenv", 42 | ".env", 43 | "config", 44 | "settings", 45 | "process", 46 | "process.env", 47 | "defaults" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | function upperSnakeCase(str: string): string { 2 | return str 3 | .split(/(?=[A-Z])/) 4 | .join('_') 5 | .toUpperCase(); 6 | } 7 | 8 | const FALSEY_VALUES = ['false', '0']; 9 | 10 | export function Env(variableName?: string | string[]): any { 11 | return (target: any, key: string) => { 12 | variableName ??= upperSnakeCase(key); 13 | 14 | let envVarValue: string | undefined = void 0; 15 | if (Array.isArray(variableName)) { 16 | const existingKey = variableName.find( 17 | (k) => typeof process.env[k] === 'string', 18 | ); 19 | if (typeof existingKey === 'string') { 20 | envVarValue = process.env[existingKey]; 21 | } 22 | } else { 23 | envVarValue = process.env[variableName]; 24 | } 25 | 26 | let outValue: string; 27 | if (typeof envVarValue === 'undefined') { 28 | if (typeof target[key] === 'undefined') { 29 | throw new Error(`CLASSENV: Environment variable ${JSON.stringify(variableName)} is undefined and default value for field ${JSON.stringify(key)} not set`); 30 | } else { 31 | outValue = target[key]; 32 | } 33 | } else { 34 | outValue = envVarValue; 35 | } 36 | const DesignType = (Reflect as any).getMetadata('design:type', target, key); 37 | if ([String, Number, Boolean].includes(DesignType)) { 38 | if ( 39 | DesignType === Boolean && 40 | FALSEY_VALUES.includes(outValue.toLowerCase()) 41 | ) { 42 | outValue = ''; 43 | } 44 | outValue = DesignType(outValue); 45 | Object.defineProperty(target, key, { 46 | value: outValue, 47 | writable: false, 48 | enumerable: true, 49 | configurable: true, 50 | }); 51 | } else { 52 | throw new Error( 53 | `CLASSENV: ${key} type must be one of [String, Number, Boolean]. Got ${DesignType.name}`, 54 | ); 55 | } 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env'; 2 | -------------------------------------------------------------------------------- /test/env.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Env } from '../src'; 3 | 4 | describe('env decorator', () => { 5 | process.env['TEST_TEST'] = '1'; 6 | process.env['BOOLEAN_FALSE_STRING'] = 'false'; 7 | process.env['BOOLEAN_ZERO_STRING'] = '0'; 8 | 9 | class Environment { 10 | @Env('TEST_TEST') 11 | static testStr: string; 12 | @Env('TEST_TEST') 13 | static testNmbr: number; 14 | @Env('TEST_TEST') 15 | static testBln: boolean; 16 | @Env() 17 | static testTest: number; 18 | @Env() 19 | static withDefault: string = 'yeah its me'; 20 | @Env() 21 | static booleanFalseString: boolean; 22 | @Env() 23 | static booleanZeroString: boolean; 24 | @Env() 25 | testTest!: number; 26 | } 27 | 28 | it('should throw when variable not exists', () => { 29 | expect(() => Env('not-existing')()).toThrow(); 30 | }); 31 | 32 | it('should cast types', () => { 33 | expect(typeof Environment.testStr).toBe('string'); 34 | expect(typeof Environment.testNmbr).toBe('number'); 35 | expect(typeof Environment.testBln).toBe('boolean'); 36 | expect(typeof Environment.testTest).toBe('number'); 37 | }); 38 | 39 | it('should auto convert to upper-snake-case', () => { 40 | expect(typeof Environment.testTest).toBe('number'); 41 | }); 42 | 43 | it('should not throw when default value is set', () => { 44 | expect(Environment.withDefault).toBe('yeah its me'); 45 | }); 46 | 47 | it('should throw when trying to mutate', () => { 48 | expect(() => (Environment.testStr = '1')).toThrow(); 49 | }); 50 | 51 | it('should support instance properties', () => { 52 | const env = new Environment(); 53 | expect(typeof env.testTest).toBe('number'); 54 | }); 55 | 56 | it('should cast strings "false", "FALSE" and "0" to boolean for boolean types', () => { 57 | expect(Environment.booleanFalseString).toStrictEqual(false); 58 | expect(Environment.booleanZeroString).toStrictEqual(false); 59 | }); 60 | 61 | describe('array variable name', () => { 62 | it('should take first existing environment variable in a right order', () => { 63 | class Environment { 64 | @Env(['NON-EXISTING', 'TEST_TEST', 'BOOLEAN_FALSE_STRING']) 65 | test!: string; 66 | } 67 | const env = new Environment; 68 | expect(env.test).toBe('1'); 69 | 70 | }); 71 | }); 72 | }); -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.checks.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Strict Checks 4 | "alwaysStrict": true, 5 | "noImplicitAny": true, 6 | "strictNullChecks": true, 7 | "useUnknownInCatchVariables": true, 8 | "strictPropertyInitialization": true, 9 | "strictFunctionTypes": true, 10 | "noImplicitThis": true, 11 | "strictBindCallApply": true, 12 | "noPropertyAccessFromIndexSignature": true, 13 | "noUncheckedIndexedAccess": true, 14 | // Linter Checks 15 | "noImplicitReturns": true, 16 | // https://eslint.org/docs/rules/consistent-return ? 17 | "noFallthroughCasesInSwitch": true, 18 | // https://eslint.org/docs/rules/no-fallthrough 19 | "noUnusedLocals": true, 20 | // https://eslint.org/docs/rules/no-unused-vars 21 | "noUnusedParameters": true, 22 | // https://eslint.org/docs/rules/no-unused-vars#args 23 | "allowUnreachableCode": false, 24 | // https://eslint.org/docs/rules/no-unreachable ? 25 | "allowUnusedLabels": false, 26 | // https://eslint.org/docs/rules/no-unused-labels 27 | // Base Strict Checks 28 | "noImplicitUseStrict": false, 29 | "suppressExcessPropertyErrors": false, 30 | "suppressImplicitAnyIndexErrors": false, 31 | "noStrictGenericChecks": false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.checks.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "removeComments": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | } 15 | } 16 | --------------------------------------------------------------------------------