Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 16 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
149 changes: 149 additions & 0 deletions src/define-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @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<T> — opaque wrapper that holds the schema + parse logic
* - defineConfig() — factory function; the primary public API
*/

// Only type references to `z` are used in this file (ZodObject, ZodRawShape, z.output<T>)
// so `import type` satisfies the @typescript-eslint/consistent-type-imports rule
import type { 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<z.ZodRawShape>;

// ─────────────────────────────────────────────────────────────────────────────
// 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<ZodObject<{ PORT: ZodDefault<ZodString> }>>
* ```
*/
export class ConfigDefinition<T extends AnyZodObject> {
/**
* 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<string, string | undefined> = process.env): Readonly<z.output<T>> {
// 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<z.output<T>>;
}
}

// ─────────────────────────────────────────────────────────────────────────────
// 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<T>` — 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<typeof appConfig>) {}
* // config.get('PORT') → number (not string | undefined)
* ```
*/
export function defineConfig<T extends AnyZodObject>(schema: T): ConfigDefinition<T> {
// Wrap the schema in a ConfigDefinition; no side effects at definition time
return new ConfigDefinition(schema);
}
64 changes: 64 additions & 0 deletions src/errors/config-validation.error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading