From dc1967b1333039802740f5e64bee8a0ac93ad91e Mon Sep 17 00:00:00 2001 From: "J.McLain" Date: Tue, 31 Mar 2026 17:20:04 -0700 Subject: [PATCH 1/3] feat: support Node.js 18 and update json validator to accept caller-provided types --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 5 ++++- README.md | 17 +++++++++-------- ROADMAP.md | 3 ++- TASKS.md | 2 +- examples/next.ts | 2 +- examples/node.ts | 2 +- package-lock.json | 2 +- package.json | 2 +- src/validators/json.ts | 6 +++--- test/create-env.test.ts | 8 +++++--- test/validators.test.ts | 10 ++++++---- tsup.config.ts | 2 +- 13 files changed, 36 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb2907..cd21692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20] + node-version: [18, 20] steps: - name: Checkout diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f4ca21..4ed0785 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,9 @@ on: jobs: verify: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20] permissions: contents: read @@ -18,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: ${{ matrix.node-version }} cache: npm - name: Verify tag matches package version diff --git a/README.md b/README.md index 248ba25..8e0c9d6 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Without validation, bad config leaks into runtime and fails late. `ts-env-valida npm install ts-env-validator ``` -Node.js `>=20` is supported. +Node.js `>=18` is supported. ## Quick start @@ -52,7 +52,7 @@ export const env = createEnv({ ENABLE_CACHE: boolean().default(false), MAX_RETRIES: integer().default(3), LATENCY_THRESHOLD: float().default(0.75), - FEATURE_FLAGS: json().optional(), + FEATURE_FLAGS: json<{ enabled: boolean; limit: number }>().optional(), LOG_LEVEL: enumOf(["debug", "info", "warn", "error"]).optional(), }); ``` @@ -79,7 +79,7 @@ env.ALLOWED_HOSTS; // string[] env.FEATURE_FLAGS; -// unknown +// { enabled: boolean; limit: number } | undefined env.LOG_LEVEL; // "debug" | "info" | "warn" | "error" | undefined @@ -194,15 +194,16 @@ DATABASE_URL: url() For example, `"https://example.com"` becomes `"https://example.com/"`. -#### `json()` +#### `json()` -Parses any valid JSON and returns `unknown`. +Parses any valid JSON and returns the caller-provided type. ```ts -FEATURE_FLAGS: json() +FEATURE_FLAGS: json<{ enabled: boolean; limit: number }>() ``` This validator accepts JSON objects, arrays, primitives, and `null`. +Typing is compile-time only; runtime validation is still plain `JSON.parse`. #### `array(separator?)` @@ -308,7 +309,7 @@ export const env = createEnv({ JWT_SECRET: string(), MAX_RETRIES: integer().default(3), LATENCY_THRESHOLD: float().default(0.75), - SERVICE_METADATA: json().optional(), + SERVICE_METADATA: json<{ region: string; team: string }>().optional(), }); ``` @@ -320,7 +321,7 @@ import { array, createEnv, json, string } from "ts-env-validator"; export const env = createEnv({ NEXT_PUBLIC_ENABLED_LOCALES: array(), NEXT_PUBLIC_API_URL: string(), - NEXT_PUBLIC_THEME_CONFIG: json().optional(), + NEXT_PUBLIC_THEME_CONFIG: json<{ accent: string; compact: boolean }>().optional(), }); ``` diff --git a/ROADMAP.md b/ROADMAP.md index 18204b6..69b75c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ ## 🎯 Vision A lightweight, TypeScript-first environment variable validator that: + - Validates at runtime - Infers types at compile time - Provides excellent developer experience @@ -52,7 +53,7 @@ Completed: - `integer()` - `float()` -- `json()` +- `json()` - `array(separator?)` --- diff --git a/TASKS.md b/TASKS.md index 0f49ca8..f928a02 100644 --- a/TASKS.md +++ b/TASKS.md @@ -161,7 +161,7 @@ - [x] Add `integer()` - [x] Add `float()` -- [x] Add `json()` +- [x] Add `json()` - [x] Add `array(separator?)` - [x] Reuse existing validator architecture and modifiers - [x] Add unit tests for all new validators diff --git a/examples/next.ts b/examples/next.ts index 15a8dd3..4cd79c0 100644 --- a/examples/next.ts +++ b/examples/next.ts @@ -3,5 +3,5 @@ import { array, createEnv, json, string } from "../src/index"; export const env = createEnv({ NEXT_PUBLIC_ENABLED_LOCALES: array(), NEXT_PUBLIC_API_URL: string(), - NEXT_PUBLIC_THEME_CONFIG: json().optional(), + NEXT_PUBLIC_THEME_CONFIG: json<{ accent: string; compact: boolean }>().optional(), }); diff --git a/examples/node.ts b/examples/node.ts index 4e6ae26..90ba60a 100644 --- a/examples/node.ts +++ b/examples/node.ts @@ -16,5 +16,5 @@ export const env = createEnv({ LATENCY_THRESHOLD: float().default(0.75), MAX_RETRIES: integer().default(3), PORT: number().default(3000), - SERVICE_METADATA: json().optional(), + SERVICE_METADATA: json<{ region: string; team: string }>().optional(), }); diff --git a/package-lock.json b/package-lock.json index cd17652..47faa08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "vitest": "^3.2.4" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 4ff371a..e87798b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "LICENSE" ], "engines": { - "node": ">=20" + "node": ">=18" }, "publishConfig": { "access": "public" diff --git a/src/validators/json.ts b/src/validators/json.ts index 5ab3768..842ecea 100644 --- a/src/validators/json.ts +++ b/src/validators/json.ts @@ -1,8 +1,8 @@ import { createValidator } from "../validator"; import { formatReceivedValue } from "../utils/format-value"; -export function json() { - return createValidator({ +export function json() { + return createValidator({ expected: "JSON", parse: (input) => { if (input === "") { @@ -15,7 +15,7 @@ export function json() { try { return { success: true, - value: JSON.parse(input) as unknown, + value: JSON.parse(input) as T, }; } catch { return { diff --git a/test/create-env.test.ts b/test/create-env.test.ts index 6a88187..3fb1b81 100644 --- a/test/create-env.test.ts +++ b/test/create-env.test.ts @@ -21,7 +21,7 @@ describe("createEnv", () => { ALLOWED_HOSTS: array(), DATABASE_URL: url(), ENABLE_CACHE: boolean().default(false), - FEATURE_FLAGS: json().optional(), + FEATURE_FLAGS: json<{ enabled: boolean; limit: number }>().optional(), LATENCY_THRESHOLD: float().default(0.75), MAX_RETRIES: integer().default(3), JWT_SECRET: string(), @@ -57,7 +57,9 @@ describe("createEnv", () => { expectTypeOf(env.LATENCY_THRESHOLD).toEqualTypeOf(); expectTypeOf(env.ENABLE_CACHE).toEqualTypeOf(); expectTypeOf(env.ALLOWED_HOSTS).toEqualTypeOf(); - expectTypeOf(env.FEATURE_FLAGS).toEqualTypeOf(); + expectTypeOf(env.FEATURE_FLAGS).toEqualTypeOf< + { enabled: boolean; limit: number } | undefined + >(); expectTypeOf(env.LOG_LEVEL).toEqualTypeOf< "debug" | "info" | "warn" | "error" | undefined >(); @@ -100,7 +102,7 @@ describe("createEnv", () => { it("parses new v0.2.0 validators in mixed schemas", () => { const env = createEnv( { - FEATURE_FLAGS: json(), + FEATURE_FLAGS: json<{ beta: boolean; limit: number }>(), LATENCY_THRESHOLD: float(), MAX_RETRIES: integer(), SCOPES: array(";"), diff --git a/test/validators.test.ts b/test/validators.test.ts index 8b95b51..a0a048e 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -119,8 +119,8 @@ describe("validators", () => { }); }); - it("parses JSON values and returns unknown", () => { - expect(json().parse('{"enabled":true,"count":2}')).toEqual({ + it("parses JSON values and supports caller-provided typing", () => { + expect(json<{ enabled: boolean; count: number }>().parse('{"enabled":true,"count":2}')).toEqual({ success: true, value: { count: 2, @@ -212,8 +212,10 @@ describe("validators", () => { expectTypeOf(array().default(["a"]).defaultValue).toEqualTypeOf< string[] | undefined >(); - expectTypeOf(json().default({ enabled: true } as unknown).defaultValue).toEqualTypeOf< - unknown + expectTypeOf( + json<{ enabled: boolean }>().default({ enabled: true }).defaultValue, + ).toEqualTypeOf< + { enabled: boolean } | undefined >(); expectTypeOf(enumOf(["development", "production"])).toMatchTypeOf( enumOf(["development", "production"]), diff --git a/tsup.config.ts b/tsup.config.ts index 9722028..e5118fb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,5 +7,5 @@ export default defineConfig({ format: ["esm", "cjs"], outDir: "dist", sourcemap: true, - target: "node20", + target: "node18", }); From 2349cc912a0811e9fc636ebcfb3f81062c81fff0 Mon Sep 17 00:00:00 2001 From: "J.McLain" Date: Tue, 31 Mar 2026 18:02:03 -0700 Subject: [PATCH 2/3] doc: add post v0.3.0 roadmap outlining future features and enhancements --- POST_ROADMAP.md | 312 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 POST_ROADMAP.md diff --git a/POST_ROADMAP.md b/POST_ROADMAP.md new file mode 100644 index 0000000..b85f6c7 --- /dev/null +++ b/POST_ROADMAP.md @@ -0,0 +1,312 @@ +# ts-env-validator — Post v0.3.0 Roadmap + +## Vision + +Evolve `ts-env-validator` into: + +> A type-safe configuration contract toolkit for TypeScript applications + +Not just validation, but: + +- environment correctness +- developer tooling +- CI enforcement +- documentation generation +- framework integration + +## Strategy Overview + +We will expand in 5 directions: + +1. Core Library Depth +2. CLI & Tooling +3. Framework Adapters +4. CI / DevOps Integration +5. Ecosystem Expansion + +## Version Roadmap + +## v0.4.0 — Advanced Validators + +### Goal + +Make the library production-ready for most real-world apps. + +### Features + +#### New Validators + +- `integer()` +- `float()` +- `port()` +- `email()` +- `uuid()` +- `json()` +- `array(separator?)` +- `date()` +- `duration()` + +#### Enhancements + +- Improved parsing error messages +- Better boolean parsing edge cases + +## v0.5.0 — Constraints & Transformations + +### Goal + +Move from parsing to validation contracts. + +### Features + +#### Constraints + +- `.min(n)` +- `.max(n)` +- `.length(n)` +- `.nonempty()` +- `.matches(regex)` + +#### Transformations + +- `.transform(fn)` +- `.refine(fn, message?)` +- `.pipe(validator)` + +### Example + +```ts +API_KEY: string().min(32) + +BASE_URL: string() + .transform((v) => v.trim()) + .refine((v) => v.startsWith("https://"), "Must use HTTPS") +``` + +## v0.6.0 — CLI (Major Feature) + +### Goal + +Make the package useful beyond runtime. + +### New Package + +- `@ts-env-validator/cli` + +### Commands + +#### `check` + +Validate environment without running the app. + +```bash +ts-env-validator check +``` + +#### `generate-example` + +Generate `.env.example` from schema. + +```bash +ts-env-validator generate-example +``` + +#### `sync-docs` + +Generate Markdown docs from schema. + +```bash +ts-env-validator sync-docs +``` + +#### `diff` + +Compare schema vs actual env. + +```bash +ts-env-validator diff +``` + +## v0.7.0 — Framework Adapters + +### Goal + +Drive adoption through framework-specific DX. + +### New Packages + +- `@ts-env-validator/next` +- `@ts-env-validator/vite` + +### Next.js Adapter + +```ts +const env = createNextEnv({ + server: { + DATABASE_URL: url(), + JWT_SECRET: string(), + }, + client: { + NEXT_PUBLIC_API_URL: url(), + }, +}); +``` + +### Features + +- server/client separation +- prefix enforcement +- build-time validation option + +### Vite Adapter + +- enforce `VITE_` prefix +- typed client env + +## v0.8.0 — Security & Secrets + +### Goal + +Introduce production-grade safety. + +### Features + +#### Secret Awareness + +- `.secret()` +- `.sensitive()` + +#### Behavior + +- hide values in error output +- redact logs + +#### Security Checks + +- detect weak secrets +- warn on default placeholders like `changeme` + +## v0.9.0 — Namespaces & Monorepo Support + +### Goal + +Support large systems and microservices. + +### Features + +#### Namespaces + +```ts +db: namespace("DB_", { + HOST: string(), + PORT: port(), +}) +``` + +#### Monorepo Support + +- shared schemas +- workspace validation CLI +- multi-service env validation + +## v1.0.0 — Stable Release + +### Goal + +Production-ready ecosystem. + +### Requirements + +- Stable API +- Full documentation +- Performance optimizations +- Plugin system (optional) +- CLI maturity +- Framework adapters stable + +## Cross-Cutting Features + +These evolve across versions. + +### Documentation Generation + +#### Features + +- Generate Markdown config docs +- Include: + - type + - required/optional + - default + - description + +### Schema Export + +#### Features + +- export schema to JSON +- reusable across tools + +### CI Integration + +#### GitHub Action + +- `@ts-env-validator/github-action` + +#### Features + +- validate `.env.example` +- enforce schema consistency +- PR feedback + +### Testing Utilities + +Future helpers: + +- mock env generator +- test schema validation +- snapshot env configs + +## Ecosystem Plan + +Split into modular packages: + +- `ts-env-validator` (core) +- `@ts-env-validator/cli` +- `@ts-env-validator/next` +- `@ts-env-validator/vite` +- `@ts-env-validator/github-action` +- `@ts-env-validator/docs` + +## High Impact Features (Priority) + +If limited time, prioritize: + +- `.env.example` generation +- CLI (`check`, `generate-example`) +- Next.js adapter +- secret-aware validation +- documentation generation + +## Success Metrics + +- npm downloads growth +- GitHub stars +- usage in real projects +- adoption in CI pipelines +- developer feedback + +## Long-Term Vision + +Position the library as: + +> The standard way to define and validate configuration in TypeScript applications + +## Guiding Principle + +Do not just add validators. + +Build: + +- a system +- a workflow +- a developer habit From 995b3c869aaa99235f2c147f19c20063e6ccee51 Mon Sep 17 00:00:00 2001 From: "J.McLain" Date: Wed, 1 Apr 2026 14:32:16 -0700 Subject: [PATCH 3/3] feat: implement custom validator API and enhance validation capabilities --- POST_ROADMAP.md | 260 +--------------------------------------- README.md | 48 +++++++- ROADMAP.md | 144 +++++++++------------- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 8 ++ src/validator.ts | 54 +++++++-- test/create-env.test.ts | 110 +++++++++++++++++ test/validators.test.ts | 98 +++++++++++++++ 9 files changed, 373 insertions(+), 355 deletions(-) diff --git a/POST_ROADMAP.md b/POST_ROADMAP.md index b85f6c7..87ba122 100644 --- a/POST_ROADMAP.md +++ b/POST_ROADMAP.md @@ -1,262 +1,8 @@ -# ts-env-validator — Post v0.3.0 Roadmap +# Post-Roadmap Notes -## Vision +This file is intentionally retired from active planning. -Evolve `ts-env-validator` into: - -> A type-safe configuration contract toolkit for TypeScript applications - -Not just validation, but: - -- environment correctness -- developer tooling -- CI enforcement -- documentation generation -- framework integration - -## Strategy Overview - -We will expand in 5 directions: - -1. Core Library Depth -2. CLI & Tooling -3. Framework Adapters -4. CI / DevOps Integration -5. Ecosystem Expansion - -## Version Roadmap - -## v0.4.0 — Advanced Validators - -### Goal - -Make the library production-ready for most real-world apps. - -### Features - -#### New Validators - -- `integer()` -- `float()` -- `port()` -- `email()` -- `uuid()` -- `json()` -- `array(separator?)` -- `date()` -- `duration()` - -#### Enhancements - -- Improved parsing error messages -- Better boolean parsing edge cases - -## v0.5.0 — Constraints & Transformations - -### Goal - -Move from parsing to validation contracts. - -### Features - -#### Constraints - -- `.min(n)` -- `.max(n)` -- `.length(n)` -- `.nonempty()` -- `.matches(regex)` - -#### Transformations - -- `.transform(fn)` -- `.refine(fn, message?)` -- `.pipe(validator)` - -### Example - -```ts -API_KEY: string().min(32) - -BASE_URL: string() - .transform((v) => v.trim()) - .refine((v) => v.startsWith("https://"), "Must use HTTPS") -``` - -## v0.6.0 — CLI (Major Feature) - -### Goal - -Make the package useful beyond runtime. - -### New Package - -- `@ts-env-validator/cli` - -### Commands - -#### `check` - -Validate environment without running the app. - -```bash -ts-env-validator check -``` - -#### `generate-example` - -Generate `.env.example` from schema. - -```bash -ts-env-validator generate-example -``` - -#### `sync-docs` - -Generate Markdown docs from schema. - -```bash -ts-env-validator sync-docs -``` - -#### `diff` - -Compare schema vs actual env. - -```bash -ts-env-validator diff -``` - -## v0.7.0 — Framework Adapters - -### Goal - -Drive adoption through framework-specific DX. - -### New Packages - -- `@ts-env-validator/next` -- `@ts-env-validator/vite` - -### Next.js Adapter - -```ts -const env = createNextEnv({ - server: { - DATABASE_URL: url(), - JWT_SECRET: string(), - }, - client: { - NEXT_PUBLIC_API_URL: url(), - }, -}); -``` - -### Features - -- server/client separation -- prefix enforcement -- build-time validation option - -### Vite Adapter - -- enforce `VITE_` prefix -- typed client env - -## v0.8.0 — Security & Secrets - -### Goal - -Introduce production-grade safety. - -### Features - -#### Secret Awareness - -- `.secret()` -- `.sensitive()` - -#### Behavior - -- hide values in error output -- redact logs - -#### Security Checks - -- detect weak secrets -- warn on default placeholders like `changeme` - -## v0.9.0 — Namespaces & Monorepo Support - -### Goal - -Support large systems and microservices. - -### Features - -#### Namespaces - -```ts -db: namespace("DB_", { - HOST: string(), - PORT: port(), -}) -``` - -#### Monorepo Support - -- shared schemas -- workspace validation CLI -- multi-service env validation - -## v1.0.0 — Stable Release - -### Goal - -Production-ready ecosystem. - -### Requirements - -- Stable API -- Full documentation -- Performance optimizations -- Plugin system (optional) -- CLI maturity -- Framework adapters stable - -## Cross-Cutting Features - -These evolve across versions. - -### Documentation Generation - -#### Features - -- Generate Markdown config docs -- Include: - - type - - required/optional - - default - - description - -### Schema Export - -#### Features - -- export schema to JSON -- reusable across tools - -### CI Integration - -#### GitHub Action - -- `@ts-env-validator/github-action` - -#### Features - -- validate `.env.example` -- enforce schema consistency -- PR feedback +Use [ROADMAP.md](/Users/joshmclain/code/ts-env-validator/ROADMAP.md) as the only roadmap and release-planning source of truth. ### Testing Utilities diff --git a/README.md b/README.md index 8e0c9d6..a2b8670 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,52 @@ SCOPES: array(";") - Empty items are rejected - Separators are treated literally +### Custom validators + +#### `createValidator({ expected, parse })` + +Creates a custom validator that works with `createEnv`, type inference, and all existing modifiers. + +```ts +import { createValidator } from "ts-env-validator"; + +const port = createValidator({ + expected: "port", + parse: (input) => { + const value = Number.parseInt(input, 10); + + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return { + success: false, + message: `expected port, received ${JSON.stringify(input)}`, + }; + } + + return { + success: true, + value, + }; + }, +}); +``` + +Use it in schemas just like a built-in validator: + +```ts +const env = createEnv({ + PORT: port.default(3000), +}); +``` + +Parser contract: + +- `parse` receives the raw string value +- return `{ success: true, value }` on success +- return `{ success: false, message }` on failure +- keep parsers synchronous +- if `parse` throws an `Error`, its `message` is surfaced as the validation failure +- if `parse` throws a non-`Error` value, `ts-env-validator` falls back to `expected ${expected}, received ...` + ### Modifiers #### `.optional()` @@ -335,7 +381,7 @@ npm test npm run build ``` -Maintainers: pushing a version tag like `v0.2.0` runs the publish workflow, releasing `ts-env-validator` to npmjs and `@/ts-env-validator` to GitHub Packages. The published npm tarball includes both `README.md` and `LICENSE`. +Maintainers: pushing a version tag like `v0.3.0` runs the publish workflow, releasing `ts-env-validator` to npmjs and `@/ts-env-validator` to GitHub Packages. The published npm tarball includes both `README.md` and `LICENSE`. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 69b75c0..bd3a9f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,122 +1,96 @@ # ts-env-validator — Roadmap -## 🎯 Vision +`ROADMAP.md` is the single source of truth for planned releases. -A lightweight, TypeScript-first environment variable validator that: +## Vision -- Validates at runtime -- Infers types at compile time -- Provides excellent developer experience -- Works across Node, Next.js, and serverless environments +Build a lightweight, TypeScript-first environment validator that: ---- +- validates at runtime +- infers types at compile time +- stays small and framework-agnostic +- gives helpful startup failures instead of late runtime surprises -## 🚀 Milestones +## Shipped -### v0.1.0 — MVP (Initial Release) - -#### Core Features +### v0.1.0 — Core MVP - `createEnv(schema, options?)` -- Validators: - - `string()` - - `number()` - - `boolean()` - - `enumOf([...])` - - `url()` - -#### Modifiers - -- `.optional()` -- `.default(value)` -- `.describe(text)` - -#### Behavior - -- Reads from `process.env` -- Supports custom env object -- Coerces types (string → number/boolean/etc.) -- Collects ALL errors before throwing -- Strong TypeScript inference - -#### DX Features - -- Clean error formatting -- Helpful error messages -- Zero config usage - ---- +- validators: `string()`, `number()`, `boolean()`, `enumOf([...])`, `url()` +- modifiers: `.optional()`, `.default(value)`, `.describe(text)` +- aggregated error reporting +- custom env source support ### v0.2.0 — Extended Types -Completed: - - `integer()` - `float()` - `json()` - `array(separator?)` ---- +## Next -### v0.3.0 — Extensibility (Next) +### v0.3.0 — Extensibility Foundation -- Custom validator API -- `.transform(fn)` -- `.refine(fn)` +Goal: let users create first-class custom validators without expanding the validation model yet. ---- +- public `createValidator({ expected, parse })` API +- public validator and parser result types +- custom validators work with `createEnv` +- custom validators support `.optional()`, `.default()`, and `.describe()` +- thrown parser errors are normalized into validation failures -### v0.4.0 — Ecosystem Enhancements +Explicitly deferred: -- dotenv helper -- `.env.example` validator CLI -- Next.js helpers (server/client env separation) +- `.transform(fn)` +- `.refine(fn)` +- constraint-style modifiers like `.min()` and `.max()` ---- +## Provisional -### v1.0.0 — Stable Release +### v0.4.0 — Validation Constraints -- Finalized API -- Performance optimizations -- Full documentation -- Community feedback incorporated +Potential focus: ---- +- string and number constraints +- `.transform(fn)` +- `.refine(fn, message?)` +- more composable validator pipelines + +### v0.5.0 — Tooling -## 🧠 Design Principles +Potential focus: -- Minimal API surface -- Strong typing by default -- Fail fast at runtime -- Clear, readable errors -- Framework agnostic -- No unnecessary dependencies +- dotenv helper +- `.env.example` generation or validation +- schema-driven docs or CLI support ---- +### v0.6.0 — Framework DX -## ❌ Explicit Non-Goals (v1) +Potential focus: -- Nested schemas -- Async validation -- Framework-specific wrappers -- Over-engineered abstractions +- Next.js helpers for server and client separation +- prefix enforcement for client-safe env keys +- framework-specific docs, not core package coupling ---- +### v1.0.0 — Stable Release -## 📦 Release Strategy +- stable public API +- polished docs +- performance review +- community feedback incorporated -| Version | Focus | -|--------|------| -| 0.1.0 | Core functionality | -| 0.2.0 | More types | -| 0.3.0 | Extensibility | -| 1.0.0 | Stability | +## Design Principles ---- +- minimal API surface +- strong typing by default +- readable errors +- sync, startup-time validation +- composable internals without overengineering -## 📈 Success Metrics +## Non-Goals -- Easy adoption in new projects -- Clean TypeScript inference -- Positive developer feedback -- GitHub stars / npm downloads +- nested schemas +- async validation +- framework-specific wrappers in the core package +- broad transform pipelines before the base validator contract is stable diff --git a/package-lock.json b/package-lock.json index 47faa08..d9ab197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ts-env-validator", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ts-env-validator", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/package.json b/package.json index e87798b..f3f7fd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-env-validator", - "version": "0.2.0", + "version": "0.3.0", "description": "Type-safe environment variable validation with runtime checks and TypeScript inference.", "keywords": [ "env", diff --git a/src/index.ts b/src/index.ts index 2a57f6b..d059335 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { array } from "./validators/array"; export { createEnv } from "./create-env"; export { EnvValidationError } from "./errors"; +export { createValidator } from "./validator"; export { boolean } from "./validators/boolean"; export { enumOf } from "./validators/enum"; export { float } from "./validators/float"; @@ -9,3 +10,10 @@ export { json } from "./validators/json"; export { number } from "./validators/number"; export { string } from "./validators/string"; export { url } from "./validators/url"; +export type { + Parser, + ValidationFailure, + ValidationResult, + ValidationSuccess, +} from "./types"; +export type { EnvSchema, InferEnv, InferValidator, Validator } from "./validator"; diff --git a/src/validator.ts b/src/validator.ts index c5c1677..0fb1657 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,10 +1,28 @@ import type { Parser, ValidationResult, ValidatorConfig } from "./types"; +import { formatReceivedValue } from "./utils/format-value"; -export class EnvValidator< +export interface Validator< T, TOptional extends boolean = false, THasDefault extends boolean = false, > { + parse(input: string): ValidationResult; + optional(): Validator; + default(value: T): Validator; + describe(text: string): Validator; + readonly expected: string; + readonly description: string | undefined; + readonly hasDefault: boolean; + readonly isOptional: boolean; + readonly defaultValue: T | undefined; +} + +class EnvValidator< + T, + TOptional extends boolean = false, + THasDefault extends boolean = false, +> implements Validator +{ private readonly config: ValidatorConfig; public constructor(config: ValidatorConfig) { @@ -15,13 +33,13 @@ export class EnvValidator< return this.config.parse(input); } - public optional(): EnvValidator { + public optional(): Validator { return this.clone({ isOptional: true, }); } - public default(value: T): EnvValidator { + public default(value: T): Validator { return this.clone({ defaultValue: value, hasDefault: true, @@ -29,7 +47,7 @@ export class EnvValidator< }); } - public describe(text: string): EnvValidator { + public describe(text: string): Validator { return this.clone({ description: text, }); @@ -60,7 +78,7 @@ export class EnvValidator< TNextHasDefault extends boolean = THasDefault, >( overrides: Partial>, - ): EnvValidator { + ): Validator { return new EnvValidator({ ...this.config, ...overrides, @@ -68,9 +86,9 @@ export class EnvValidator< } } -export type AnyValidator = EnvValidator; +export type AnyValidator = Validator; -export type InferValidator = TValidator extends EnvValidator< +export type InferValidator = TValidator extends Validator< infer TValue, infer TOptional, infer THasDefault @@ -91,11 +109,29 @@ export type InferEnv = { export function createValidator(config: { expected: string; parse: Parser; -}): EnvValidator { +}): Validator { + const parse: Parser = (input) => { + try { + return config.parse(input); + } catch (error) { + if (error instanceof Error) { + return { + message: error.message, + success: false, + }; + } + + return { + message: `expected ${config.expected}, received ${formatReceivedValue(input)}`, + success: false, + }; + } + }; + return new EnvValidator({ expected: config.expected, hasDefault: false, isOptional: false, - parse: config.parse, + parse, }); } diff --git a/test/create-env.test.ts b/test/create-env.test.ts index 3fb1b81..bbb115b 100644 --- a/test/create-env.test.ts +++ b/test/create-env.test.ts @@ -5,6 +5,7 @@ import { array, boolean, createEnv, + createValidator, enumOf, float, integer, @@ -99,6 +100,48 @@ describe("createEnv", () => { expect(env.API_KEY).toBe("test-key"); }); + it("supports custom validators alongside built-in validators", () => { + const port = createValidator({ + expected: "port", + parse: (input) => { + const value = Number.parseInt(input, 10); + + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return { + message: `expected port, received ${JSON.stringify(input)}`, + success: false, + }; + } + + return { + success: true, + value, + }; + }, + }); + + const env = createEnv( + { + API_URL: url(), + LOG_LEVEL: enumOf(["debug", "info"]).optional(), + PORT: port.default(3000), + }, + { + env: { + API_URL: "https://example.com", + }, + }, + ); + + expect(env).toEqual({ + API_URL: "https://example.com/", + LOG_LEVEL: undefined, + PORT: 3000, + }); + expectTypeOf(env.PORT).toEqualTypeOf(); + expectTypeOf(env.LOG_LEVEL).toEqualTypeOf<"debug" | "info" | undefined>(); + }); + it("parses new v0.2.0 validators in mixed schemas", () => { const env = createEnv( { @@ -239,6 +282,73 @@ describe("createEnv", () => { } }); + it("aggregates custom validator failures with missing variables", () => { + const port = createValidator({ + expected: "port", + parse: () => { + throw new Error("port must be between 1 and 65535"); + }, + }); + + try { + createEnv( + { + API_KEY: string().describe("Service API key"), + PORT: port.describe("Application port"), + }, + { + env: { + PORT: "70000", + }, + }, + ); + throw new Error("Expected createEnv to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + expect((error as EnvValidationError).message).toBe( + [ + "Environment validation failed", + "", + "Missing required variables:", + "- API_KEY (Service API key)", + "", + "Invalid variables:", + "- PORT (Application port): port must be between 1 and 65535", + ].join("\n"), + ); + } + }); + + it("infers required, optional, and defaulted custom validator results", () => { + const dateString = createValidator({ + expected: "date", + parse: (input) => ({ + success: true, + value: new Date(input), + }), + }); + + const env = createEnv( + { + END_DATE: dateString.optional(), + RELEASE_DATE: dateString, + START_DATE: dateString.default(new Date("2026-01-01T00:00:00.000Z")), + }, + { + env: { + RELEASE_DATE: "2026-04-01T12:00:00.000Z", + }, + }, + ); + + expect(env.END_DATE).toBeUndefined(); + expect(env.RELEASE_DATE).toBeInstanceOf(Date); + expect(env.START_DATE).toBeInstanceOf(Date); + expectTypeOf(env.END_DATE).toEqualTypeOf(); + expectTypeOf(env.RELEASE_DATE).toEqualTypeOf(); + expectTypeOf(env.START_DATE).toEqualTypeOf(); + }); + it("allows optional values to resolve to undefined", () => { const env = createEnv( { diff --git a/test/validators.test.ts b/test/validators.test.ts index a0a048e..0ae4878 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { array, boolean, + createValidator, enumOf, float, integer, @@ -180,6 +181,90 @@ describe("validators", () => { expect(withDefault.defaultValue).toBe(3000); }); + it("creates custom validators with typed outputs", () => { + const port = createValidator({ + expected: "port", + parse: (input) => { + const value = Number.parseInt(input, 10); + + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return { + message: `expected port, received ${JSON.stringify(input)}`, + success: false, + }; + } + + return { + success: true, + value, + }; + }, + }); + + expect(port.parse("3000")).toEqual({ + success: true, + value: 3000, + }); + expect(port.parse("70000")).toEqual({ + message: 'expected port, received "70000"', + success: false, + }); + expectTypeOf(port.parse).toBeCallableWith("3000"); + expectTypeOf(port.default(3000).defaultValue).toEqualTypeOf< + number | undefined + >(); + }); + + it("supports modifier chaining on custom validators", () => { + const base = createValidator({ + expected: "port", + parse: (input) => ({ + success: true, + value: Number.parseInt(input, 10), + }), + }); + const described = base.describe("Application port"); + const optional = described.optional(); + const withDefault = optional.default(3000); + + expect(base.description).toBeUndefined(); + expect(base.isOptional).toBe(false); + expect(base.hasDefault).toBe(false); + + expect(described.description).toBe("Application port"); + expect(optional.isOptional).toBe(true); + expect(withDefault.hasDefault).toBe(true); + expect(withDefault.defaultValue).toBe(3000); + }); + + it("converts thrown parser errors into validation failures", () => { + const validator = createValidator({ + expected: "port", + parse: () => { + throw new Error("port must be between 1 and 65535"); + }, + }); + + expect(validator.parse("70000")).toEqual({ + message: "port must be between 1 and 65535", + success: false, + }); + }); + + it("uses a fallback error when a parser throws a non-Error value", () => { + const validator = createValidator({ + expected: "port", + parse: () => { + throw "boom"; + }, + }); + + expect(validator.parse("70000")).toEqual({ + message: 'expected port, received "70000"', + success: false, + }); + }); + it("exposes helpful error metadata", () => { const error = new EnvValidationError({ invalid: [ @@ -217,6 +302,19 @@ describe("validators", () => { ).toEqualTypeOf< { enabled: boolean } | undefined >(); + expectTypeOf(createValidator({ + expected: "date", + parse: (input) => ({ + success: true, + value: new Date(input), + }), + })).toMatchTypeOf(createValidator({ + expected: "date", + parse: (input) => ({ + success: true, + value: new Date(input), + }), + })); expectTypeOf(enumOf(["development", "production"])).toMatchTypeOf( enumOf(["development", "production"]), );