diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b773a22..865ba79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22] + node-version: [20, 22, 24] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/README.md b/README.md index 44547df..fef27ff 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Works with Zod, Valibot, ArkType, and other Standard Schema-compatible validatio - 🔌 **Standard Schema compliant** - Works with any compatible validation library - 🌐 **Runtime agnostic** - Runs anywhere (Node, Bun, Deno, browsers) - 🏗️ **Structured configuration** - Supports nested config objects +- 🧮 **Computed values** - Derive values from parsed config with full type inference - 🚦 **Environment detection** - `isProduction`, `isTest`, `isDevelopment` flags - 📜 **Detailed error reporting** - See all validation failures at once - 🚀 **Lightweight** - Single dependency (type-fest), zero runtime overhead @@ -132,6 +133,101 @@ type Config = InferEnv; // { apiKey: string; db: { host: string } } ``` +### Computed Values + +Use `createConfig` to derive computed values from your parsed configuration with full type inference: + +```typescript +import { createConfig, envvar } from 'envase'; +import { z } from 'zod'; + +const config = createConfig(process.env, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + name: envvar('DB_NAME', z.string()), + }, + api: { + key: envvar('API_KEY', z.string()), + }, + }, + computed: { + dbConnectionString: (raw) => + `postgres://${raw.db.host}:${raw.db.port}/${raw.db.name}`, + apiKeyPrefix: (raw) => raw.api.key.slice(0, 8), + }, +}); + +// config.db.host -> string +// config.db.port -> number +// config.dbConnectionString -> string +// config.apiKeyPrefix -> string +``` + +The `raw` parameter in computed functions is fully typed based on your schema, providing autocomplete and type checking. Computed values are calculated after schema validation, so you always work with parsed values (e.g., `port` is a `number`, not a string). + +#### Nested Computed Values + +Computed values can be nested to merge with your schema structure: + +```typescript +import { createConfig, envvar } from 'envase'; +import { z } from 'zod'; + +const config = createConfig(process.env, { + schema: { + aws: { + accessKeyId: envvar('AWS_ACCESS_KEY_ID', z.string()), + secretAccessKey: envvar('AWS_SECRET_ACCESS_KEY', z.string()), + }, + }, + computed: { + aws: { + credentials: (raw) => ({ + accessKeyId: raw.aws.accessKeyId, + secretAccessKey: raw.aws.secretAccessKey, + }), + }, + }, +}); + +// Result type: +// { +// aws: { +// accessKeyId: string; +// secretAccessKey: string; +// credentials: { accessKeyId: string; secretAccessKey: string }; +// } +// } +``` + +You can also mix flat and nested computed values: + +```typescript +const config = createConfig(process.env, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + computed: { + // Flat at root level + dbUrl: (raw) => `${raw.db.host}:${raw.db.port}`, + // Nested under existing schema key + db: { + connectionString: (raw) => `postgres://${raw.db.host}:${raw.db.port}`, + }, + }, +}); + +// config.dbUrl -> string +// config.db.host -> string +// config.db.port -> number +// config.db.connectionString -> string +``` + ## CLI Automatically generate and validate markdown documentation from your environment variable schemas. @@ -277,6 +373,16 @@ This helps pair the raw env name with the shape you expect it to conform to. Validates envvars against the schema and returns a typed configuration object. +### `createConfig` + +`createConfig(env, options)` + +Validates envvars and optionally computes derived values. Returns a merged object containing both the parsed config and computed values. All types are inferred from the schema and computed functions. + +- `env` - Environment variables object (e.g., `process.env`) +- `options.schema` - Environment variable schema (same format as `parseEnv`) +- `options.computed` - Optional object where each key is a function receiving the parsed config and returning a derived value + ### `detectNodeEnv` `detectNodeEnv(env: Record)` diff --git a/package-lock.json b/package-lock.json index 768a8ec..c6b1865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "cac": "^6.7.14", "diff": "^8.0.3", - "type-fest": "^5.4.1" + "type-fest": "^5.4.2" }, "bin": { "envase": "dist/cli/main.js" @@ -20,12 +20,12 @@ "@biomejs/biome": "^2.1.3", "@lokalise/tsconfig": "^2.0.0", "@types/node": "^25.0.9", - "@vitest/coverage-v8": "^4.0.17", - "rimraf": "^6.0.1", - "typescript": "^5.9.2", + "@vitest/coverage-v8": "^4.0.18", + "rimraf": "^6.1.2", + "typescript": "^5.9.3", "valibot": "^1.0.0", - "vitest": "^4.0.17", - "zod": "^4.0.15" + "vitest": "^4.0.18", + "zod": "^4.3.6" } }, "node_modules/@babel/helper-string-parser": { @@ -752,9 +752,9 @@ "license": "Apache-2.0" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.3.tgz", - "integrity": "sha512-qyX8+93kK/7R5BEXPC2PjUt0+fS/VO2BVHjEHyIEWiYn88rcRBHmdLgoJjktBltgAf+NY7RfCGB1SoyKS/p9kg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", "cpu": [ "arm" ], @@ -766,9 +766,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.3.tgz", - "integrity": "sha512-6sHrL42bjt5dHQzJ12Q4vMKfN+kUnZ0atHHnv4V0Wd9JMTk7FDzSY35+7qbz3ypQYMBPANbpGK7JpnWNnhGt8g==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", "cpu": [ "arm64" ], @@ -780,9 +780,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.3.tgz", - "integrity": "sha512-1ht2SpGIjEl2igJ9AbNpPIKzb1B5goXOcmtD0RFxnwNuMxqkR6AUaaErZz+4o+FKmzxcSNBOLrzsICZVNYa1Rw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", "cpu": [ "arm64" ], @@ -794,9 +794,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.3.tgz", - "integrity": "sha512-FYZ4iVunXxtT+CZqQoPVwPhH7549e/Gy7PIRRtq4t5f/vt54pX6eG9ebttRH6QSH7r/zxAFA4EZGlQ0h0FvXiA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", "cpu": [ "x64" ], @@ -808,9 +808,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.3.tgz", - "integrity": "sha512-M/mwDCJ4wLsIgyxv2Lj7Len+UMHd4zAXu4GQ2UaCdksStglWhP61U3uowkaYBQBhVoNpwx5Hputo8eSqM7K82Q==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", "cpu": [ "arm64" ], @@ -822,9 +822,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.3.tgz", - "integrity": "sha512-5jZT2c7jBCrMegKYTYTpni8mg8y3uY8gzeq2ndFOANwNuC/xJbVAoGKR9LhMDA0H3nIhvaqUoBEuJoICBudFrA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", "cpu": [ "x64" ], @@ -836,9 +836,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.3.tgz", - "integrity": "sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", "cpu": [ "arm" ], @@ -850,9 +850,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.3.tgz", - "integrity": "sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", "cpu": [ "arm" ], @@ -864,9 +864,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.3.tgz", - "integrity": "sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", "cpu": [ "arm64" ], @@ -878,9 +878,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.3.tgz", - "integrity": "sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", "cpu": [ "arm64" ], @@ -892,9 +892,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.3.tgz", - "integrity": "sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", "cpu": [ "loong64" ], @@ -906,9 +906,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.3.tgz", - "integrity": "sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", "cpu": [ "loong64" ], @@ -920,9 +920,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.3.tgz", - "integrity": "sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", "cpu": [ "ppc64" ], @@ -934,9 +934,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.3.tgz", - "integrity": "sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", "cpu": [ "ppc64" ], @@ -948,9 +948,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.3.tgz", - "integrity": "sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", "cpu": [ "riscv64" ], @@ -962,9 +962,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.3.tgz", - "integrity": "sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", "cpu": [ "riscv64" ], @@ -976,9 +976,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.3.tgz", - "integrity": "sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", "cpu": [ "s390x" ], @@ -990,9 +990,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.3.tgz", - "integrity": "sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", "cpu": [ "x64" ], @@ -1004,9 +1004,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.3.tgz", - "integrity": "sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", "cpu": [ "x64" ], @@ -1018,9 +1018,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.3.tgz", - "integrity": "sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", "cpu": [ "x64" ], @@ -1032,9 +1032,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.3.tgz", - "integrity": "sha512-vo54aXwjpTtsAnb3ca7Yxs9t2INZg7QdXN/7yaoG7nPGbOBXYXQY41Km+S1Ov26vzOAzLcAjmMdjyEqS1JkVhw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", "cpu": [ "arm64" ], @@ -1046,9 +1046,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.3.tgz", - "integrity": "sha512-HI+PIVZ+m+9AgpnY3pt6rinUdRYrGHvmVdsNQ4odNqQ/eRF78DVpMR7mOq7nW06QxpczibwBmeQzB68wJ+4W4A==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", "cpu": [ "arm64" ], @@ -1060,9 +1060,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.3.tgz", - "integrity": "sha512-vRByotbdMo3Wdi+8oC2nVxtc3RkkFKrGaok+a62AT8lz/YBuQjaVYAS5Zcs3tPzW43Vsf9J0wehJbUY5xRSekA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", "cpu": [ "ia32" ], @@ -1074,9 +1074,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.3.tgz", - "integrity": "sha512-POZHq7UeuzMJljC5NjKi8vKMFN6/5EOqcX1yGntNLp7rUTpBAXQ1hW8kWPFxYLv07QMcNM75xqVLGPWQq6TKFA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", "cpu": [ "x64" ], @@ -1088,9 +1088,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.3.tgz", - "integrity": "sha512-aPFONczE4fUFKNXszdvnd2GqKEYQdV5oEsIbKPujJmWlCI9zEsv1Otig8RKK+X9bed9gFUN6LAeN4ZcNuu4zjg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", "cpu": [ "x64" ], @@ -1145,14 +1145,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", - "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1166,8 +1166,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.17", - "vitest": "4.0.17" + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1176,16 +1176,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -1194,13 +1194,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1221,9 +1221,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -1234,13 +1234,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -1248,13 +1248,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1263,9 +1263,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -1273,13 +1273,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1725,9 +1725,9 @@ } }, "node_modules/rollup": { - "version": "4.55.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", - "integrity": "sha512-y9yUpfQvetAjiDLtNMf1hL9NXchIJgWt6zIKeoB+tCd3npX08Eqfzg60V9DhIGVMtQ0AlMkFw5xa+AQ37zxnAA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", "dev": true, "license": "MIT", "dependencies": { @@ -1741,31 +1741,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.3", - "@rollup/rollup-android-arm64": "4.55.3", - "@rollup/rollup-darwin-arm64": "4.55.3", - "@rollup/rollup-darwin-x64": "4.55.3", - "@rollup/rollup-freebsd-arm64": "4.55.3", - "@rollup/rollup-freebsd-x64": "4.55.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.3", - "@rollup/rollup-linux-arm-musleabihf": "4.55.3", - "@rollup/rollup-linux-arm64-gnu": "4.55.3", - "@rollup/rollup-linux-arm64-musl": "4.55.3", - "@rollup/rollup-linux-loong64-gnu": "4.55.3", - "@rollup/rollup-linux-loong64-musl": "4.55.3", - "@rollup/rollup-linux-ppc64-gnu": "4.55.3", - "@rollup/rollup-linux-ppc64-musl": "4.55.3", - "@rollup/rollup-linux-riscv64-gnu": "4.55.3", - "@rollup/rollup-linux-riscv64-musl": "4.55.3", - "@rollup/rollup-linux-s390x-gnu": "4.55.3", - "@rollup/rollup-linux-x64-gnu": "4.55.3", - "@rollup/rollup-linux-x64-musl": "4.55.3", - "@rollup/rollup-openbsd-x64": "4.55.3", - "@rollup/rollup-openharmony-arm64": "4.55.3", - "@rollup/rollup-win32-arm64-msvc": "4.55.3", - "@rollup/rollup-win32-ia32-msvc": "4.55.3", - "@rollup/rollup-win32-x64-gnu": "4.55.3", - "@rollup/rollup-win32-x64-msvc": "4.55.3", + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" } }, @@ -1883,9 +1883,9 @@ } }, "node_modules/type-fest": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz", - "integrity": "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.2.tgz", + "integrity": "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -2011,20 +2011,20 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -2052,10 +2052,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -2107,9 +2107,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index d3c5a0f..440aa8e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "build": "rimraf dist && tsc -p tsconfig.build.json", "lint": "biome check . && tsc", "lint:fix": "biome check --write .", - "test": "vitest --coverage", + "test": "vitest --typecheck --coverage", "prepublishOnly": "npm run build" }, "bin": { @@ -46,17 +46,17 @@ "dependencies": { "cac": "^6.7.14", "diff": "^8.0.3", - "type-fest": "^5.4.1" + "type-fest": "^5.4.2" }, "devDependencies": { "@biomejs/biome": "^2.1.3", "@lokalise/tsconfig": "^2.0.0", "@types/node": "^25.0.9", - "@vitest/coverage-v8": "^4.0.17", - "rimraf": "^6.0.1", - "typescript": "^5.9.2", + "@vitest/coverage-v8": "^4.0.18", + "rimraf": "^6.1.2", + "typescript": "^5.9.3", "valibot": "^1.0.0", - "vitest": "^4.0.17", - "zod": "^4.0.15" + "vitest": "^4.0.18", + "zod": "^4.3.6" } } diff --git a/src/core.test.ts b/src/core.test.ts index 3afffdb..bc6d282 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -1,7 +1,7 @@ import * as v from 'valibot'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { z } from 'zod'; -import { detectNodeEnv, envvar, parseEnv } from './core.ts'; +import { createConfig, detectNodeEnv, envvar, parseEnv } from './core.ts'; describe('core', () => { describe('detectNodeEnv', () => { @@ -217,4 +217,373 @@ describe('core', () => { }); }); }); + + describe('createConfig', () => { + const mockEnv = { + DB_HOST: 'localhost', + DB_PORT: '5432', + DB_NAME: 'mydb', + API_KEY: 'secret123', + }; + + it('parses config without computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + }); + + expect(config.db.host).toBe('localhost'); + expect(config.db.port).toBe(5432); + }); + + it('computes derived values from raw config', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + name: envvar('DB_NAME', z.string()), + }, + api: { + key: envvar('API_KEY', z.string()), + }, + }, + computed: { + dbConnectionString: (raw) => + `postgres://${raw.db.host}:${raw.db.port}/${raw.db.name}`, + apiKeyPrefix: (raw) => raw.api.key.slice(0, 8), + }, + }); + + expect(config.db.host).toBe('localhost'); + expect(config.db.port).toBe(5432); + expect(config.db.name).toBe('mydb'); + expect(config.api.key).toBe('secret123'); + expect(config.dbConnectionString).toBe('postgres://localhost:5432/mydb'); + expect(config.apiKeyPrefix).toBe('secret12'); + }); + + it('receives parsed values in computed functions (not raw strings)', () => { + const config = createConfig(mockEnv, { + schema: { + port: envvar('DB_PORT', z.coerce.number()), + }, + computed: { + portPlusTen: (raw) => raw.port + 10, + }, + }); + + expect(config.port).toBe(5432); + expect(config.portPlusTen).toBe(5442); + }); + + it('throws EnvaseError if schema validation fails before computing', () => { + expect(() => + createConfig(mockEnv, { + schema: { + missing: envvar('MISSING_VAR', z.string()), + }, + computed: { + derived: (raw) => raw.missing.toUpperCase(), + }, + }), + ).toThrow(); + }); + + it('works with empty computed object', () => { + const config = createConfig(mockEnv, { + schema: { + host: envvar('DB_HOST', z.string()), + }, + computed: {}, + }); + + expect(config.host).toBe('localhost'); + }); + + it('infers types correctly for raw parameter and return values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + computed: { + connectionString: (raw) => `${raw.db.host}:${raw.db.port}`, + portPlusTen: (raw) => raw.db.port + 10, + }, + }); + + // These type assertions verify compile-time inference + const _host: string = config.db.host; + const _port: number = config.db.port; + const _connStr: string = config.connectionString; + const _portPlusTen: number = config.portPlusTen; + + expect(_host).toBe('localhost'); + expect(_port).toBe(5432); + expect(_connStr).toBe('localhost:5432'); + expect(_portPlusTen).toBe(5442); + }); + + it('deep merges nested computed values with schema', () => { + const config = createConfig( + { + AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE', + AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + { + schema: { + aws: { + accessKeyId: envvar('AWS_ACCESS_KEY_ID', z.string()), + secretAccessKey: envvar('AWS_SECRET_ACCESS_KEY', z.string()), + }, + }, + computed: { + aws: { + credentials: (raw) => ({ + accessKeyId: raw.aws.accessKeyId, + secretAccessKey: raw.aws.secretAccessKey, + }), + }, + }, + }, + ); + + // Verify schema values are preserved + expect(config.aws.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE'); + expect(config.aws.secretAccessKey).toBe( + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + ); + + // Verify computed value is merged in + expect(config.aws.credentials).toEqual({ + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }); + + // Type assertions + const _accessKeyId: string = config.aws.accessKeyId; + const _credentials: { + accessKeyId: string; + secretAccessKey: string; + } = config.aws.credentials; + + expect(_accessKeyId).toBeDefined(); + expect(_credentials).toBeDefined(); + }); + + it('supports multiple levels of nested computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + name: envvar('DB_NAME', z.string()), + }, + }, + computed: { + db: { + connection: { + url: (raw) => + `postgres://${raw.db.host}:${raw.db.port}/${raw.db.name}`, + }, + }, + }, + }); + + expect(config.db.host).toBe('localhost'); + expect(config.db.port).toBe(5432); + expect(config.db.name).toBe('mydb'); + expect(config.db.connection.url).toBe('postgres://localhost:5432/mydb'); + }); + + it('allows mixing flat and nested computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + api: { + key: envvar('API_KEY', z.string()), + }, + }, + computed: { + // Flat computed value at root + dbUrl: (raw) => `${raw.db.host}:${raw.db.port}`, + // Nested computed value + api: { + keyPrefix: (raw) => raw.api.key.slice(0, 4), + }, + }, + }); + + expect(config.dbUrl).toBe('localhost:5432'); + expect(config.api.key).toBe('secret123'); + expect(config.api.keyPrefix).toBe('secr'); + }); + + describe('type inference', () => { + it('infers correct types for config without computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + }); + + expectTypeOf(config).toEqualTypeOf<{ + db: { host: string; port: number }; + }>(); + }); + + it('infers correct types for flat computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + computed: { + url: (raw) => `${raw.db.host}:${raw.db.port}`, + portPlusTen: (raw) => raw.db.port + 10, + }, + }); + + expectTypeOf(config.db).toEqualTypeOf<{ host: string; port: number }>(); + expectTypeOf(config.url).toEqualTypeOf(); + expectTypeOf(config.portPlusTen).toEqualTypeOf(); + }); + + it('infers correct types for nested computed values merged with schema', () => { + const config = createConfig( + { + AWS_ACCESS_KEY_ID: 'test', + AWS_SECRET_ACCESS_KEY: 'test', + }, + { + schema: { + aws: { + accessKeyId: envvar('AWS_ACCESS_KEY_ID', z.string()), + secretAccessKey: envvar('AWS_SECRET_ACCESS_KEY', z.string()), + }, + }, + computed: { + aws: { + credentials: (raw) => ({ + key: raw.aws.accessKeyId, + secret: raw.aws.secretAccessKey, + }), + }, + }, + }, + ); + + expectTypeOf(config.aws.accessKeyId).toEqualTypeOf(); + expectTypeOf(config.aws.secretAccessKey).toEqualTypeOf(); + expectTypeOf(config.aws.credentials).toEqualTypeOf<{ + key: string; + secret: string; + }>(); + }); + + it('infers correct types for deeply nested computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + computed: { + db: { + connection: { + url: (raw) => `postgres://${raw.db.host}:${raw.db.port}`, + isLocal: (raw) => raw.db.host === 'localhost', + }, + }, + }, + }); + + expectTypeOf(config.db.host).toEqualTypeOf(); + expectTypeOf(config.db.port).toEqualTypeOf(); + expectTypeOf(config.db.connection.url).toEqualTypeOf(); + expectTypeOf(config.db.connection.isLocal).toEqualTypeOf(); + + // Also verify runtime behavior + expect(config.db.host).toBe('localhost'); + expect(config.db.port).toBe(5432); + expect(config.db.connection.url).toBe('postgres://localhost:5432'); + expect(config.db.connection.isLocal).toBe(true); + }); + + it('infers correct types when mixing flat and nested computed values', () => { + const config = createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + }, + api: { + key: envvar('API_KEY', z.string()), + }, + }, + computed: { + isConfigured: (raw) => raw.db.host.length > 0, + db: { + isLocal: (raw) => raw.db.host === 'localhost', + }, + api: { + keyLength: (raw) => raw.api.key.length, + }, + }, + }); + + expectTypeOf(config.db.host).toEqualTypeOf(); + expectTypeOf(config.db.isLocal).toEqualTypeOf(); + expectTypeOf(config.api.key).toEqualTypeOf(); + expectTypeOf(config.api.keyLength).toEqualTypeOf(); + expectTypeOf(config.isConfigured).toEqualTypeOf(); + }); + + it('infers optional schema values correctly', () => { + const config = createConfig(mockEnv, { + schema: { + optional: envvar('MISSING', z.string().optional()), + withDefault: envvar('ALSO_MISSING', z.string().default('default')), + }, + }); + + expectTypeOf(config.optional).toEqualTypeOf(); + expectTypeOf(config.withDefault).toEqualTypeOf(); + }); + + it('infers raw parameter type correctly in computed functions', () => { + createConfig(mockEnv, { + schema: { + db: { + host: envvar('DB_HOST', z.string()), + port: envvar('DB_PORT', z.coerce.number()), + }, + }, + computed: { + test: (raw) => { + // Verify raw parameter has correct type + expectTypeOf(raw.db.host).toEqualTypeOf(); + expectTypeOf(raw.db.port).toEqualTypeOf(); + return true; + }, + }, + }); + }); + }); + }); }); diff --git a/src/core.ts b/src/core.ts index c0af998..40c5441 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,9 +1,11 @@ import { EnvaseError } from './errors/envase-error.ts'; import type { StandardSchemaV1 } from './standard-schema.ts'; import type { + ComputedSchema, EnvSchema, EnvvarEntry, EnvvarValidationIssue, + InferConfig, InferEnv, NodeEnvInfo, } from './types.ts'; @@ -76,3 +78,104 @@ export const parseEnv = ( return config; }; + +// Helper to check if value is a resolver function +const isResolver = (value: unknown): value is (raw: unknown) => unknown => + typeof value === 'function'; + +// Helper to process computed schema recursively +const processComputed = ( + computed: Record, + rawConfig: unknown, +): Record => { + return Object.fromEntries( + Object.entries(computed).map(([key, value]) => [ + key, + isResolver(value) + ? value(rawConfig) + : processComputed(value as Record, rawConfig), + ]), + ); +}; + +// Helper to deep merge two objects +const deepMerge = ( + target: Record, + source: Record, +): Record => { + const result = { ...target }; + + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = result[key]; + + if ( + sourceValue && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) && + targetValue && + typeof targetValue === 'object' && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record, + ); + } else { + result[key] = sourceValue; + } + } + + return result; +}; + +// Overload: without computed +export function createConfig( + env: Record, + options: { + schema: TSchema; + computed?: undefined; + }, +): InferEnv; + +// Overload: with computed +export function createConfig< + TSchema extends EnvSchema, + const TComputed extends ComputedSchema>, +>( + env: Record, + options: { + schema: TSchema; + computed: TComputed; + }, +): InferConfig, TComputed>; + +// Implementation +export function createConfig< + TSchema extends EnvSchema, + TComputed extends ComputedSchema>, +>( + env: Record, + options: { + schema: TSchema; + computed?: TComputed; + }, + // biome-ignore lint/suspicious/noExplicitAny: Required for overload implementation +): any { + // Parse raw config using existing parseEnv + const rawConfig = parseEnv(env, options.schema); + + // If no computed values, return raw config + if (!options.computed) { + return rawConfig; + } + + // Compute derived values (handles nested structures) + const computedValues = processComputed( + options.computed as Record, + rawConfig, + ); + + // Deep merge raw config with computed values + return deepMerge(rawConfig as Record, computedValues); +} diff --git a/src/index.ts b/src/index.ts index c7b14fc..26ffff7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,8 @@ -export { detectNodeEnv, envvar, parseEnv } from './core.ts'; +export { createConfig, detectNodeEnv, envvar, parseEnv } from './core.ts'; export { EnvaseError } from './errors/envase-error.ts'; -export type { InferEnv } from './types.ts'; +export type { + ComputedSchema, + InferComputed, + InferConfig, + InferEnv, +} from './types.ts'; diff --git a/src/types.ts b/src/types.ts index c812faf..c177c2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { SimplifyDeep } from 'type-fest'; +import type { MergeDeep, SimplifyDeep } from 'type-fest'; import type { StandardSchemaV1 } from './standard-schema.ts'; export type NodeEnvInfo = { @@ -28,3 +28,26 @@ export type EnvvarValidationIssue = { value?: string; messages: string[]; }; + +// Resolver function type for computed values +// biome-ignore lint/suspicious/noExplicitAny: Required for type inference +type ComputedResolver = (raw: any) => unknown; + +// Schema for computed values - can be nested objects or resolver functions +export type ComputedSchema = { + [key: string]: ((raw: TRaw) => unknown) | ComputedSchema; +}; + +// Infer output types from computed schema (handles nested structures) +export type InferComputed = { + [K in keyof T]: T[K] extends ComputedResolver + ? ReturnType + : T[K] extends object + ? InferComputed + : never; +}; + +// Combined result type (raw config deep merged with computed values) +export type InferConfig = SimplifyDeep< + MergeDeep> +>; diff --git a/vitest.config.ts b/vitest.config.ts index 755d73a..af8c0cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,5 +10,8 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/index.ts'], }, + typecheck: { + include: ['./src/**/*.test.ts'], + }, }, });