diff --git a/README.md b/README.md
index 2427e87..7299eec 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ Click on one of the links to access the documentation of the package:
| result | [@openally/result](./src/result) |
| auto-url | [@openally/auto-url](./src/auto-url) |
| config | [@openally/config](./src/config) |
+| env | [@openally/env](./src/env) |
These packages are available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com).
```bash
diff --git a/package-lock.json b/package-lock.json
index cb0a949..aa73fe7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,8 @@
"src/mutex",
"src/result",
"src/auto-url",
- "src/config"
+ "src/config",
+ "src/env"
],
"devDependencies": {
"@faker-js/faker": "^9.2.0",
@@ -1156,6 +1157,10 @@
"integrity": "sha512-qJ85Zil/pR1/eR2/4j6jsqRoAAjH+1D+0A7+FhvbLv4TcjvgYmjzDt5bl9/AK2wzPP5Ci+KetVR0tbc22Hdmqw==",
"dev": true
},
+ "node_modules/@openally/env": {
+ "resolved": "src/env",
+ "link": true
+ },
"node_modules/@openally/ephemeral-map": {
"resolved": "src/ephemeral-map",
"link": true
@@ -4249,6 +4254,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
+ "node_modules/ts-pattern": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz",
+ "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==",
+ "license": "MIT"
+ },
"node_modules/tsup": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.5.tgz",
@@ -5067,6 +5078,15 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "src/env": {
+ "name": "@openally/env",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@openally/result": "^1.3.0",
+ "ts-pattern": "^5.3.1"
+ }
+ },
"src/ephemeral-map": {
"name": "@openally/ephemeral-map",
"version": "1.3.2",
@@ -5842,6 +5862,13 @@
"integrity": "sha512-qJ85Zil/pR1/eR2/4j6jsqRoAAjH+1D+0A7+FhvbLv4TcjvgYmjzDt5bl9/AK2wzPP5Ci+KetVR0tbc22Hdmqw==",
"dev": true
},
+ "@openally/env": {
+ "version": "file:src/env",
+ "requires": {
+ "@openally/result": "^1.3.0",
+ "ts-pattern": "^5.3.1"
+ }
+ },
"@openally/ephemeral-map": {
"version": "file:src/ephemeral-map",
"requires": {
@@ -8009,6 +8036,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
+ "ts-pattern": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz",
+ "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA=="
+ },
"tsup": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.5.tgz",
diff --git a/package.json b/package.json
index 8692899..f7f6702 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"src/mutex",
"src/result",
"src/auto-url",
- "src/config"
+ "src/config",
+ "src/env"
],
"license": "MIT",
"bugs": {
diff --git a/src/env/README.md b/src/env/README.md
new file mode 100644
index 0000000..a209f67
--- /dev/null
+++ b/src/env/README.md
@@ -0,0 +1,46 @@
+
+ Env
+
+
+
+ Safely load/parse ENV variables
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Requirements
+- [Node.js](https://nodejs.org/en/) v20 or higher
+
+## Getting Started
+
+This package is available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com).
+
+```bash
+$ npm i @openally/env
+# or
+$ yarn add @openally/env
+```
+
+## Usage example
+
+```ts
+```
+
+## API
+TBC
+
+## License
+MIT
diff --git a/src/env/package.json b/src/env/package.json
new file mode 100644
index 0000000..2a2377e
--- /dev/null
+++ b/src/env/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@openally/env",
+ "version": "1.0.0",
+ "description": "Safely load/parse ENV variables",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "require": "./dist/index.js",
+ "import": "./dist/index.mjs",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "scripts": {
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
+ "prepublishOnly": "npm run build",
+ "test": "glob -c \"tsx --test\" \"./test/**/*.spec.ts\"",
+ "coverage": "c8 --all --src ./src -r html npm test",
+ "lint": "cross-env eslint src/**/*.ts"
+ },
+ "files": [
+ "dist"
+ ],
+ "keywords": [],
+ "author": "GENTILHOMME Thomas ",
+ "license": "MIT",
+ "dependencies": {
+ "@openally/result": "^1.3.0",
+ "ts-pattern": "^5.3.1"
+ }
+}
diff --git a/src/env/src/class/EnvLoader.class.ts b/src/env/src/class/EnvLoader.class.ts
new file mode 100644
index 0000000..e7c2f53
--- /dev/null
+++ b/src/env/src/class/EnvLoader.class.ts
@@ -0,0 +1,135 @@
+// Import Third-party Dependencies
+import { Err, Ok, Some, None, type Result } from "@openally/result";
+import { match } from "ts-pattern";
+
+// Import Internal Dependencies
+import {
+ SafeEnvNotFound,
+ EnvNotFound,
+ SafeUnknowEnvType,
+ UnknowEnvType
+} from "../errors.js";
+import {
+ type ExtractDescriptorType,
+ type AllEnvTypeDescriptor,
+ type StringTypeParameters,
+ ENV_STRING,
+ ENV_BOOLEAN,
+ ENV_NUMBER
+} from "../types.js";
+import * as utils from "../utils/index.js";
+
+// CONSTANTS
+function kNoopParsingHook() {
+ return void 0;
+}
+
+export type ParsingState = "found" | "notFound" | "parsingError";
+export type ParsingHookFunction = (envName: string, state: ParsingState, value?: any) => void;
+
+export interface EnvLoaderOptions {
+ parseHook?: ParsingHookFunction;
+ /**
+ * Avoid leaking secrets and password values
+ * @default true
+ */
+ redact?: boolean;
+ prefix?: string;
+}
+
+export interface EnvLoaderParsingOptions {
+ env?: NodeJS.ProcessEnv;
+ prefix?: string;
+}
+
+export class EnvLoader {
+ private parseHook: ParsingHookFunction;
+ private prefix: string | undefined;
+
+ constructor(
+ options: EnvLoaderOptions = {}
+ ) {
+ const {
+ parseHook = kNoopParsingHook,
+ prefix
+ } = options;
+
+ this.parseHook = parseHook;
+ this.prefix = prefix;
+ }
+
+ unsafeParse(
+ name: string,
+ descriptor: T,
+ options: EnvLoaderParsingOptions = {}
+ ): ExtractDescriptorType {
+ return this.parse(name, descriptor, options).unwrap();
+ }
+
+ parse(
+ name: string,
+ descriptor: T,
+ options: EnvLoaderParsingOptions = {}
+ ): Result, SafeEnvNotFound | SafeUnknowEnvType> {
+ const {
+ env = process.env,
+ prefix = this.prefix
+ } = options;
+
+ const finalizedName = prefix && name.startsWith(prefix) ?
+ name : `${prefix}${name}`;
+ const rawEnv = env[finalizedName];
+
+ if (typeof rawEnv === "undefined") {
+ this.parseHook(finalizedName, "notFound");
+
+ return Err(EnvNotFound.create(finalizedName));
+ }
+
+ const parsingResult = match(descriptor.type)
+ .with(ENV_STRING, () => this.#parseString(rawEnv, descriptor.parameters))
+ .with(ENV_BOOLEAN, () => this.#parseBoolean(rawEnv))
+ .with(ENV_NUMBER, () => this.#parseNumber(rawEnv))
+ .otherwise(() => None);
+
+ if (parsingResult.some) {
+ const parsedValue = parsingResult.safeUnwrap();
+ this.parseHook(
+ finalizedName, "found", utils.redactEnv(finalizedName, parsedValue)
+ );
+
+ // TODO: not sure how to fix type here
+ return Ok(parsedValue as any);
+ }
+
+ this.parseHook(
+ finalizedName, "parsingError", utils.redactEnv(finalizedName, rawEnv)
+ );
+
+ return Err(UnknowEnvType.create());
+ }
+
+ #parseString(
+ value: string,
+ parameters: StringTypeParameters
+ ) {
+ const { format } = parameters;
+
+ const trimedValue = value.trim();
+ if (format && !format.test(trimedValue)) {
+ return None;
+ }
+
+ return Some(trimedValue);
+ }
+
+ #parseBoolean(value: string) {
+ return Some(/^(?:y|yes|true|1|on)$/i.test(value.trim()));
+ }
+
+ #parseNumber(value: string) {
+ const castedValue = Number(value);
+
+ return Number.isNaN(castedValue) ? None : Some(castedValue);
+ }
+}
diff --git a/src/env/src/errors.ts b/src/env/src/errors.ts
new file mode 100644
index 0000000..fcd9ae5
--- /dev/null
+++ b/src/env/src/errors.ts
@@ -0,0 +1,29 @@
+declare const __brand: unique symbol;
+type Brand = { [__brand]: B };
+type Branded = T & Brand;
+
+function createBranded(instance: T): T & Brand {
+ return instance as T & Brand;
+}
+
+export class EnvNotFound extends Error {
+ static create(envName: string): SafeEnvNotFound {
+ return createBranded(new EnvNotFound(envName));
+ }
+
+ private constructor(envName: string) {
+ super(`Unable to find and load '${envName}'.`);
+ }
+}
+export type SafeEnvNotFound = Branded;
+
+export class UnknowEnvType extends Error {
+ static create(): SafeUnknowEnvType {
+ return createBranded(new UnknowEnvType());
+ }
+
+ private constructor() {
+ super(`Unknown Env type`);
+ }
+}
+export type SafeUnknowEnvType = Branded;
diff --git a/src/env/src/index.ts b/src/env/src/index.ts
new file mode 100644
index 0000000..7d3918f
--- /dev/null
+++ b/src/env/src/index.ts
@@ -0,0 +1,37 @@
+// Import Third-party Dependencies
+import { type Result } from "@openally/result";
+
+// Import Internal Dependencies
+import {
+ SafeEnvNotFound,
+ SafeUnknowEnvType
+} from "./errors.js";
+import { EnvLoader } from "./class/EnvLoader.class.js";
+import {
+ type EnvType,
+ type EnvTypeDescriptor,
+ type ExtractDescriptorSymbol,
+ type ExtractDescriptorType,
+ Types
+} from "./types.js";
+
+export interface PickOptions {
+ env?: NodeJS.ProcessEnv;
+}
+
+export function pick<
+ T extends EnvType = typeof Types.string
+ >(
+ name: string,
+ descriptor: T = Types.string as T,
+ options: PickOptions = {}
+): Result, SafeEnvNotFound | SafeUnknowEnvType> {
+ const finalizedDescriptor: EnvTypeDescriptor> = typeof descriptor === "function" ?
+ descriptor() :
+ descriptor;
+
+ return new EnvLoader()
+ .parse(name, finalizedDescriptor, options);
+}
+
+export { Types };
diff --git a/src/env/src/types.ts b/src/env/src/types.ts
new file mode 100644
index 0000000..97e3a15
--- /dev/null
+++ b/src/env/src/types.ts
@@ -0,0 +1,66 @@
+
+// CONSTANTS
+export const ENV_STRING: unique symbol = Symbol("env.type.string");
+export const ENV_BOOLEAN: unique symbol = Symbol("env.type.boolean");
+export const ENV_NUMBER: unique symbol = Symbol("env.type.number");
+
+export type AllEnvTypes = {
+ [ENV_STRING]: string;
+ [ENV_BOOLEAN]: boolean;
+ [ENV_NUMBER]: number;
+};
+
+export type EnvTypeDescriptor<
+ U extends symbol,
+ P extends Record = Record
+> = {
+ type: U;
+ parameters: P;
+}
+export type AllEnvTypeDescriptor = {
+ [K in keyof AllEnvTypes]: EnvTypeDescriptor;
+}[keyof AllEnvTypes];
+
+export type EnvType =
+ ((parameters?: any) => EnvTypeDescriptor) |
+ EnvTypeDescriptor;
+
+export type ExtractPrimitiveType =
+ T extends keyof AllEnvTypes ? AllEnvTypes[T] :
+ never;
+
+export type ExtractDescriptorSymbol =
+ T extends () => EnvTypeDescriptor ?
+ U :
+ T extends EnvTypeDescriptor ?
+ U :
+ never;
+
+export type ExtractDescriptorType = ExtractPrimitiveType>;
+
+export type StringTypeParameters = {
+ format?: RegExp;
+}
+
+export const Types = {
+ string(
+ parameters: StringTypeParameters = {}
+ ): EnvTypeDescriptor {
+ return {
+ type: ENV_STRING,
+ parameters
+ };
+ },
+ boolean(): EnvTypeDescriptor {
+ return {
+ type: ENV_BOOLEAN,
+ parameters: {}
+ };
+ },
+ number(): EnvTypeDescriptor {
+ return {
+ type: ENV_NUMBER,
+ parameters: {}
+ };
+ }
+} satisfies Record EnvTypeDescriptor>;
diff --git a/src/env/src/utils/index.ts b/src/env/src/utils/index.ts
new file mode 100644
index 0000000..57a1b09
--- /dev/null
+++ b/src/env/src/utils/index.ts
@@ -0,0 +1 @@
+export * from "./redactEnv.js";
diff --git a/src/env/src/utils/redactEnv.ts b/src/env/src/utils/redactEnv.ts
new file mode 100644
index 0000000..c1b6de7
--- /dev/null
+++ b/src/env/src/utils/redactEnv.ts
@@ -0,0 +1,5 @@
+export function redactEnv(name: string, value: any) {
+ return name.includes("secret") || name.includes("password") ?
+ "** REDACTED **" :
+ value;
+}
diff --git a/src/env/tsconfig.json b/src/env/tsconfig.json
new file mode 100644
index 0000000..3e74d19
--- /dev/null
+++ b/src/env/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "./src"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 05ad96b..f9720f7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,6 +18,9 @@
},
{
"path": "./src/config"
+ },
+ {
+ "path": "./src/env"
}
]
}