From 31ed73722f1ca358e305acea76dcc4e3a45c2ea4 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 13:40:29 +0100 Subject: [PATCH 1/4] feat(COMPT-50): implement Zod-based env schema validation engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add defineConfig(schema) returning ConfigDefinition - Add ConfigDefinition.parse() — validates process.env synchronously, returns frozen fully-typed config, throws ConfigValidationError on failure - Add ConfigValidationError extending Error with fields: ZodIssue[] - Add zod as peerDependency (^3 || ^4) - Install zod as devDependency for local development - Update src/index.ts to export defineConfig, ConfigDefinition, ConfigValidationError - Add node to tsconfig types array for process.env access --- package-lock.json | 21 +++- package.json | 6 +- src/define-config.ts | 147 ++++++++++++++++++++++++++ src/errors/config-validation.error.ts | 64 +++++++++++ src/index.ts | 48 +++------ tsconfig.json | 2 +- 6 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 src/define-config.ts create mode 100644 src/errors/config-validation.error.ts diff --git a/package-lock.json b/package-lock.json index 03fb84d..33977db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", + "name": "@ciscode/config-kit", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", + "name": "@ciscode/config-kit", + "version": "0.0.0", "license": "MIT", "dependencies": { "class-transformer": "^0.5.1", @@ -36,7 +36,8 @@ "tsc-alias": "^1.8.10", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "zod": "^4.3.6" }, "engines": { "node": ">=20" @@ -10303,6 +10304,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "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": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 79102c8..a36282c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "@nestjs/core": "^10 || ^11", "@nestjs/platform-express": "^10 || ^11", "reflect-metadata": "^0.2.2", - "rxjs": "^7" + "rxjs": "^7", + "zod": "^3 || ^4" }, "dependencies": { "class-transformer": "^0.5.1", @@ -77,6 +78,7 @@ "tsc-alias": "^1.8.10", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "zod": "^4.3.6" } } diff --git a/src/define-config.ts b/src/define-config.ts new file mode 100644 index 0000000..a49ca49 --- /dev/null +++ b/src/define-config.ts @@ -0,0 +1,147 @@ +/** + * @file define-config.ts + * @description + * Core validation engine for ConfigKit. + * + * Consumers call `defineConfig(zodSchema)` to declare the shape of their + * environment variables. The returned `ConfigDefinition` is passed to + * `ConfigModule.register()` which calls `.parse()` synchronously during + * NestJS module initialization — before any provider resolves. + * + * Contents: + * - ConfigDefinition — opaque wrapper that holds the schema + parse logic + * - defineConfig() — factory function; the primary public API + */ + +import { z } from "zod"; + +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Constraint for the Zod schema accepted by defineConfig. + * + * Must be a ZodObject so we can key into its shape for typed `.get()` calls + * inside ConfigService. + */ +type AnyZodObject = z.ZodObject; + +// ───────────────────────────────────────────────────────────────────────────── +// ConfigDefinition +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Opaque wrapper returned by `defineConfig()`. + * + * Holds the Zod schema and exposes a `parse()` method that ConfigModule calls + * at startup to validate `process.env`. Do not instantiate this class + * directly — always use `defineConfig()`. + * + * The generic parameter `T` carries the full Zod schema type so that + * `ConfigService` can later infer the exact output type of each key. + * + * @example + * ```typescript + * const appConfig = defineConfig(z.object({ PORT: z.string().default('3000') })); + * // typeof appConfig → ConfigDefinition }>> + * ``` + */ +export class ConfigDefinition { + /** + * The Zod schema provided by the consumer. + * ConfigModule stores this to produce injection tokens with correct types. + */ + public readonly schema: T; + + constructor(schema: T) { + // Store the schema for later use by ConfigModule + this.schema = schema; + } + + /** + * Validates the given environment record against `this.schema`. + * + * Called **synchronously** by ConfigModule during NestJS module + * initialization. If validation fails the app never finishes booting. + * + * @param env - The environment record to validate. + * Defaults to `process.env` so consumers never need to pass it. + * @returns A deep-frozen, fully-typed config object with no `string | undefined` values. + * Zod coerces types and fills in `.default()` values automatically. + * @throws {ConfigValidationError} When one or more fields fail validation. + * The error lists every failing ZodIssue so developers can fix all + * problems in a single restart cycle. + * + * @example + * ```typescript + * // Called internally by ConfigModule — you rarely call this yourself + * const config = appConfig.parse(process.env); + * config.PORT; // string (never undefined) + * config.DATABASE_URL; // string + * ``` + */ + parse(env: Record = process.env): Readonly> { + // Run a safe (non-throwing) parse so we can collect ALL issues at once + const result = this.schema.safeParse(env); + + // If validation failed, throw with the full ZodIssue list + if (!result.success) { + throw new ConfigValidationError(result.error.issues); + } + + // Freeze the resulting object so consumers cannot mutate config at runtime + return Object.freeze(result.data) as Readonly>; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// defineConfig +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Declares the shape of your application's environment variables using a Zod schema. + * + * Returns a `ConfigDefinition` that you pass to `ConfigModule.register()` or + * `ConfigModule.forRoot()`. ConfigModule will call `.parse(process.env)` at + * startup and make the result available through `ConfigService`. + * + * **No validation happens at call time** — validation is deferred to module + * initialization so that tests can import and compose schemas without a + * fully-populated environment. + * + * @param schema - A `z.object(...)` describing every env variable the module needs. + * Use `.default()` for optional variables and `.transform()` for + * type coercion (e.g. `z.coerce.number()` for port numbers). + * @returns `ConfigDefinition` — pass this to `ConfigModule.register(definition)`. + * + * @example + * ```typescript + * // config/app.config.ts + * import { defineConfig } from '@ciscode/config-kit'; + * import { z } from 'zod'; + * + * export const appConfig = defineConfig( + * z.object({ + * PORT: z.coerce.number().default(3000), + * DATABASE_URL: z.string().url(), + * JWT_SECRET: z.string().min(32), + * NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + * }), + * ); + * + * // app.module.ts + * @Module({ imports: [ConfigModule.forRoot(appConfig)] }) + * export class AppModule {} + * + * // some.service.ts + * constructor(private config: ConfigService) {} + * // config.get('PORT') → number (not string | undefined) + * ``` + */ +export function defineConfig(schema: T): ConfigDefinition { + // Wrap the schema in a ConfigDefinition; no side effects at definition time + return new ConfigDefinition(schema); +} diff --git a/src/errors/config-validation.error.ts b/src/errors/config-validation.error.ts new file mode 100644 index 0000000..8d3f9e1 --- /dev/null +++ b/src/errors/config-validation.error.ts @@ -0,0 +1,64 @@ +/** + * @file config-validation.error.ts + * @description + * Custom error class thrown when process.env fails validation against a + * consumer-provided Zod schema. + * + * Thrown synchronously during ConfigModule initialization so the NestJS app + * never boots in a misconfigured state. + * + * Contents: + * - ConfigValidationError — extends Error, carries the full ZodIssue list + */ + +import type { ZodIssue } from "zod"; + +// ───────────────────────────────────────────────────────────────────────────── +// Error class +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Thrown when one or more environment variables fail Zod schema validation. + * + * The `fields` array contains every ZodIssue that failed — callers can inspect + * it programmatically or let the built-in message surface them all at once. + * + * @example + * ```typescript + * try { + * defineConfig(schema).parse(process.env); + * } catch (err) { + * if (err instanceof ConfigValidationError) { + * console.error(err.fields); // ZodIssue[] + * } + * } + * ``` + */ +export class ConfigValidationError extends Error { + /** + * All ZodIssue objects that caused validation to fail. + * Each issue contains the field path, the failing code, and a human-readable message. + */ + public readonly fields: ZodIssue[]; + + constructor(fields: ZodIssue[]) { + // Build a multi-line message so developers immediately see what's wrong in logs + const lines = fields.map( + (issue) => + // Format each issue as " • FIELD_PATH: error message" + ` • ${issue.path.join(".") || "(root)"}: ${issue.message}`, + ); + + super(`Config validation failed:\n${lines.join("\n")}`); + + // Set the prototype explicitly so `instanceof ConfigValidationError` works + // correctly after TypeScript compiles to ES5 / CommonJS targets + Object.setPrototypeOf(this, new.target.prototype); + + // Give the error a recognisable name in stack traces + this.name = "ConfigValidationError"; + + // Store the raw ZodIssue list for programmatic inspection + this.fields = fields; + } +} diff --git a/src/index.ts b/src/index.ts index 3026198..76d04f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,34 @@ import "reflect-metadata"; // ============================================================================ -// PUBLIC API EXPORTS +// PUBLIC API EXPORTS — @ciscode/config-kit // ============================================================================ -// This file defines what consumers of your module can import. -// ONLY export what is necessary for external use. -// Keep entities, repositories, and internal implementation details private. - -// ============================================================================ -// MODULE +// Only export what consumers need. +// Internal wiring (module tokens, provider factories) stays private. // ============================================================================ -export { ExampleKitModule } from "./example-kit.module"; -export type { ExampleKitOptions, ExampleKitAsyncOptions } from "./example-kit.module"; // ============================================================================ -// SERVICES (Main API) -// ============================================================================ -// Export services that consumers will interact with -export { ExampleService } from "./services/example.service"; - +// VALIDATION ENGINE (COMPT-50) // ============================================================================ -// DTOs (Public Contracts) +// defineConfig() — declare your env shape with a Zod schema +// ConfigDefinition — the wrapper returned by defineConfig(); passed to ConfigModule // ============================================================================ -// DTOs are the public interface for your API -// Consumers depend on these, so they must be stable -export { CreateExampleDto } from "./dto/create-example.dto"; -export { UpdateExampleDto } from "./dto/update-example.dto"; +export { defineConfig, ConfigDefinition } from "./define-config"; // ============================================================================ -// GUARDS (For Route Protection) +// ERRORS // ============================================================================ -// Export guards so consumers can use them in their apps -export { ExampleGuard } from "./guards/example.guard"; - -// ============================================================================ -// DECORATORS (For Dependency Injection & Metadata) +// ConfigValidationError — thrown at startup when process.env fails the schema. +// Consumers can catch this in bootstrap() for custom error formatting. // ============================================================================ -// Export decorators for use in consumer controllers/services -export { ExampleData, ExampleParam } from "./decorators/example.decorator"; +export { ConfigValidationError } from "./errors/config-validation.error"; // ============================================================================ -// TYPES & INTERFACES (For TypeScript Typing) -// ============================================================================ -// Export types and interfaces for TypeScript consumers -// export type { YourCustomType } from './types'; - +// ❌ NEVER EXPORT (Internal implementation details) // ============================================================================ -// ❌ NEVER EXPORT (Internal Implementation) +// - Raw module provider tokens (CONFIG_KIT_OPTIONS, etc.) +// - Internal parse helpers +// - Namespace registry internals // ============================================================================ // These should NEVER be exported from a module: // - Entities (internal domain models) diff --git a/tsconfig.json b/tsconfig.json index e92d316..21aceb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["jest"], + "types": ["jest", "node"], "baseUrl": ".", "experimentalDecorators": true, "emitDecoratorMetadata": true, From 131152987c25c462c0bc1d40333d180dcb1f41a7 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 13:43:01 +0100 Subject: [PATCH 2/4] chore: bump version to 0.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33977db..58d33e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ciscode/config-kit", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciscode/config-kit", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "class-transformer": "^0.5.1", diff --git a/package.json b/package.json index a36282c..3671275 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciscode/config-kit", - "version": "0.0.0", + "version": "0.1.0", "description": "Typed env config for NestJS with Zod validation at startup. Fails fast on misconfiguration. Per-module namespace injection. Most foundational backend package.", "author": "CisCode", "publishConfig": { From 0c92aa3e66c3e8e4704b254d285f06b6e50616d9 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 13:44:14 +0100 Subject: [PATCH 3/4] style: apply prettier formatting across all files --- .github/dependabot.yml | 12 ++++++------ .github/workflows/publish.yml | 6 +++--- .github/workflows/release-check.yml | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f5c78ba..2336dd7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,20 @@ version: 2 updates: - package-ecosystem: npm - directory: '/' + directory: "/" schedule: interval: monthly open-pull-requests-limit: 1 groups: npm-dependencies: patterns: - - '*' + - "*" assignees: - CISCODE-MA/devops labels: - - 'dependencies' - - 'npm' + - "dependencies" + - "npm" commit-message: - prefix: 'chore(deps)' - include: 'scope' + prefix: "chore(deps)" + include: "scope" rebase-strategy: auto diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3b6ae92..7b5f137 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,9 +60,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index f94190f..25b39b2 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -17,9 +17,9 @@ jobs: # Config stays in the workflow file (token stays in repo secrets) env: - SONAR_HOST_URL: 'https://sonarcloud.io' - SONAR_ORGANIZATION: 'ciscode' - SONAR_PROJECT_KEY: 'CISCODE-MA_...' + SONAR_HOST_URL: "https://sonarcloud.io" + SONAR_ORGANIZATION: "ciscode" + SONAR_PROJECT_KEY: "CISCODE-MA_..." steps: - name: Checkout @@ -30,8 +30,8 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' + node-version: "22" + cache: "npm" - name: Install run: npm ci From 5c945a38a720ea7eb20a69786934a68c6d31a7aa Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 13:45:57 +0100 Subject: [PATCH 4/4] fix(lint): use import type for z in define-config.ts --- src/define-config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/define-config.ts b/src/define-config.ts index a49ca49..ed2307f 100644 --- a/src/define-config.ts +++ b/src/define-config.ts @@ -13,7 +13,9 @@ * - defineConfig() — factory function; the primary public API */ -import { z } from "zod"; +// Only type references to `z` are used in this file (ZodObject, ZodRawShape, z.output) +// so `import type` satisfies the @typescript-eslint/consistent-type-imports rule +import type { z } from "zod"; import { ConfigValidationError } from "@/errors/config-validation.error";