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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @ciscode/config-kit

## 0.3.0

### Minor Changes

- Added ConfigService.getAll(), InferConfig<T> type helper, useClass/useExisting support in registerAsync, and removed template boilerplate from the published package

## 0.2.0

### Minor Changes
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ciscode/config-kit",
"version": "0.2.0",
"version": "0.3.1",
"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
109 changes: 107 additions & 2 deletions src/config.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import { Module, Injectable } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { z } from "zod";

import { ConfigModule } from "@/config.module";
import { ConfigModule, ConfigModuleOptionsFactory } from "@/config.module";
import { ConfigService } from "@/config.service";
import { defineConfig } from "@/define-config";
import { defineConfig, ConfigDefinition } from "@/define-config";
import { ConfigValidationError } from "@/errors/config-validation.error";

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -212,3 +212,108 @@ describe("ConfigModule.forRoot()", () => {
expect(featureService.config).toBeInstanceOf(ConfigService);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// ConfigService.getAll()
// ─────────────────────────────────────────────────────────────────────────────

describe("ConfigService.getAll()", () => {
it("should return the complete validated config object", async () => {
const parsed = appDef.parse({ APP_HOST: "all-host", APP_PORT: "7777" });

const module = await Test.createTestingModule({
imports: [ConfigModule.register(appDef)],
})
.overrideProvider("CONFIG_KIT_VALUES")
.useValue(parsed)
.compile();

const service = module.get<ConfigService<typeof appDef>>(ConfigService);

// getAll() should return the full config with all keys present
const all = service.getAll();
expect(all.APP_HOST).toBe("all-host");
expect(all.APP_PORT).toBe(7777);
});

it("should return the same reference as individual get() calls", async () => {
const parsed = appDef.parse({ APP_HOST: "ref-host", APP_PORT: "1234" });

const module = await Test.createTestingModule({
imports: [ConfigModule.register(appDef)],
})
.overrideProvider("CONFIG_KIT_VALUES")
.useValue(parsed)
.compile();

const service = module.get<ConfigService<typeof appDef>>(ConfigService);

// Values returned by getAll() must match those returned by get()
const all = service.getAll();
expect(all.APP_HOST).toBe(service.get("APP_HOST"));
expect(all.APP_PORT).toBe(service.get("APP_PORT"));
});
});

// ─────────────────────────────────────────────────────────────────────────────
// ConfigModule.registerAsync() — useClass / useExisting
// ─────────────────────────────────────────────────────────────────────────────

describe("ConfigModule.registerAsync() — useClass", () => {
it("should instantiate the class and call createConfigDefinition()", async () => {
// Track whether createConfigDefinition was called on the class instance
let factoryCalled = false;

@Injectable()
class TestConfigFactory implements ConfigModuleOptionsFactory {
createConfigDefinition(): ConfigDefinition<
z.ZodObject<{ USE_CLASS_VAR: z.ZodDefault<z.ZodString> }>
> {
factoryCalled = true;
return defineConfig(z.object({ USE_CLASS_VAR: z.string().default("class-value") }));
}
}

const module = await Test.createTestingModule({
imports: [ConfigModule.registerAsync({ useClass: TestConfigFactory })],
}).compile();

expect(factoryCalled).toBe(true);
expect(module.get(ConfigService)).toBeInstanceOf(ConfigService);
});
});

describe("ConfigModule.registerAsync() — useExisting", () => {
it("should reuse the existing provider and call createConfigDefinition()", async () => {
let factoryCalled = false;

@Injectable()
class ExistingConfigFactory implements ConfigModuleOptionsFactory {
createConfigDefinition(): ConfigDefinition<
z.ZodObject<{ USE_EXISTING_VAR: z.ZodDefault<z.ZodString> }>
> {
factoryCalled = true;
return defineConfig(z.object({ USE_EXISTING_VAR: z.string().default("existing-value") }));
}
}

// Helper module that provides the factory so it can be reused via useExisting
@Module({
providers: [ExistingConfigFactory],
exports: [ExistingConfigFactory],
})
class FactoryModule {}

const module = await Test.createTestingModule({
imports: [
ConfigModule.registerAsync({
imports: [FactoryModule],
useExisting: ExistingConfigFactory,
}),
],
}).compile();

expect(factoryCalled).toBe(true);
expect(module.get(ConfigService)).toBeInstanceOf(ConfigService);
});
});
148 changes: 122 additions & 26 deletions src/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* - ConfigModule — the NestJS dynamic module
*/

import { DynamicModule, Global, Module } from "@nestjs/common";
import { DynamicModule, Global, Module, Type } from "@nestjs/common";
import type { Provider } from "@nestjs/common";
import type { z } from "zod";

Expand All @@ -51,21 +51,66 @@ type AnyZodObject = z.ZodObject<z.ZodRawShape>;
// ─────────────────────────────────────────────────────────────────────────────

/**
* Options for `ConfigModule.registerAsync()`.
* Interface that classes must implement to work with `ConfigModule.registerAsync({ useClass })`.
*
* The factory receives whatever NestJS injects via `inject[]` and must return
* a `ConfigDefinition` (the value returned by `defineConfig()`). ConfigModule
* will call `.parse(process.env)` on it before any dependent provider resolves.
* Implement this in a class and pass that class to `registerAsync({ useClass: MyFactory })`.
* NestJS will instantiate the class (injecting its constructor deps) and call
* `createConfigDefinition()` to retrieve the `ConfigDefinition`.
*
* @example
* ```typescript
* @Injectable()
* export class AppConfigFactory implements ConfigModuleOptionsFactory {
* constructor(private readonly vault: VaultService) {}
*
* async createConfigDefinition() {
* const secret = await this.vault.getSecret('JWT_SECRET');
* return defineConfig(z.object({ JWT_SECRET: z.string().default(secret) }));
* }
* }
*
* // app.module.ts
* ConfigModule.registerAsync({ useClass: AppConfigFactory });
* ```
*/
export interface ConfigModuleOptionsFactory<T extends AnyZodObject = AnyZodObject> {
/**
* Returns the `ConfigDefinition` to validate and provide.
* May be synchronous or async.
*/
createConfigDefinition(): Promise<ConfigDefinition<T>> | ConfigDefinition<T>;
}

/**
* Options for `ConfigModule.registerAsync()`.
*
* Supports three patterns — pick exactly one of `useFactory`, `useClass`, or `useExisting`:
*
* - **`useFactory`**: provide an inline factory function (most common for simple cases)
* - **`useClass`**: provide a class implementing `ConfigModuleOptionsFactory`; NestJS
* instantiates it (and injects its constructor deps) before calling `createConfigDefinition()`
* - **`useExisting`**: same as `useClass` but reuses an already-registered provider instead
* of creating a new instance (avoids double-instantiation)
*
* @example useFactory
* ```typescript
* ConfigModule.registerAsync({
* imports: [SomeModule],
* inject: [SomeService],
* useFactory: (svc: SomeService) =>
* defineConfig(z.object({ PORT: z.coerce.number().default(svc.defaultPort()) })),
* imports: [VaultModule],
* inject: [VaultService],
* useFactory: (vault: VaultService) =>
* defineConfig(z.object({ PORT: z.coerce.number().default(3000) })),
* });
* ```
*
* @example useClass
* ```typescript
* ConfigModule.registerAsync({ useClass: AppConfigFactory });
* ```
*
* @example useExisting
* ```typescript
* ConfigModule.registerAsync({ useExisting: AppConfigFactory });
* ```
*/
export interface ConfigModuleAsyncOptions<T extends AnyZodObject = AnyZodObject> {
/**
Expand All @@ -76,15 +121,27 @@ export interface ConfigModuleAsyncOptions<T extends AnyZodObject = AnyZodObject>

/**
* Tokens to inject into `useFactory` as positional arguments.
* Same semantics as `inject` in a standard NestJS `useFactory` provider.
* Only used with `useFactory`. Ignored when using `useClass` or `useExisting`.
*/
inject?: unknown[];

/**
* Factory function that returns the ConfigDefinition to validate.
* Inline factory function. Receives the `inject`-ed tokens as positional args.
* May be synchronous or async.
*/
useFactory: (...args: unknown[]) => Promise<ConfigDefinition<T>> | ConfigDefinition<T>;
useFactory?: (...args: unknown[]) => Promise<ConfigDefinition<T>> | ConfigDefinition<T>;

/**
* A class implementing `ConfigModuleOptionsFactory`. NestJS creates a fresh
* instance (with its own injected deps) and calls `createConfigDefinition()`.
*/
useClass?: Type<ConfigModuleOptionsFactory<T>>;

/**
* Same as `useClass` but reuses a provider already present in the DI container.
* The class must be provided elsewhere in `imports` or the root module.
*/
useExisting?: Type<ConfigModuleOptionsFactory<T>>;
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -196,20 +253,14 @@ export class ConfigModule {
static registerAsync<T extends AnyZodObject>(
options: ConfigModuleAsyncOptions<T>,
): DynamicModule {
// Async provider — NestJS awaits this before resolving anything that injects
// CONFIG_VALUES_TOKEN (i.e. ConfigService)
const configValuesProvider: Provider = {
provide: CONFIG_VALUES_TOKEN,
useFactory: async (...args: unknown[]) => {
// Call the consumer's factory to get the ConfigDefinition
const definition = await options.useFactory(...args);
// Build the CONFIG_VALUES_TOKEN provider differently based on which
// async pattern the consumer chose (useFactory / useClass / useExisting)
const configValuesProvider = ConfigModule.createAsyncConfigProvider(options);

// Parse and validate synchronously inside the async factory
// Throwing here prevents the app from booting with bad config
return definition.parse(process.env);
},
inject: (options.inject ?? []) as never[],
};
// Extra providers needed when consumer uses useClass (NestJS must instantiate the class)
const extraProviders: Provider[] = options.useClass
? [{ provide: options.useClass, useClass: options.useClass }]
: [];

// Namespace registry shared with feature modules' asProvider() factories
const namespaceRegistryProvider: Provider = {
Expand All @@ -221,12 +272,57 @@ export class ConfigModule {
module: ConfigModule,
// Make the imported modules available so the factory can resolve them
imports: options.imports ?? [],
providers: [configValuesProvider, namespaceRegistryProvider, ConfigService],
providers: [
configValuesProvider,
...extraProviders,
namespaceRegistryProvider,
ConfigService,
],
exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN],
global: false,
};
}

/**
* Builds the `CONFIG_VALUES_TOKEN` provider for `registerAsync`.
*
* Handles all three async patterns:
* - `useFactory`: wraps the consumer's factory directly
* - `useClass` / `useExisting`: delegates to the factory class's `createConfigDefinition()`
*/
private static createAsyncConfigProvider<T extends AnyZodObject>(
options: ConfigModuleAsyncOptions<T>,
): Provider {
if (options.useFactory) {
// ── useFactory ─────────────────────────────────────────────────────────
return {
provide: CONFIG_VALUES_TOKEN,
useFactory: async (...args: unknown[]) => {
// Call the consumer's factory to get the ConfigDefinition
const definition = await options.useFactory!(...args);
// Parse and validate synchronously — throws on bad env before app boots
return definition.parse(process.env);
},
inject: (options.inject ?? []) as never[],
};
}

// ── useClass / useExisting ────────────────────────────────────────────────
// Both patterns delegate to ConfigModuleOptionsFactory.createConfigDefinition()
// useClass → NestJS creates a fresh instance of the class
// useExisting → NestJS reuses the already-registered instance
const factoryToken = (options.useClass ?? options.useExisting)!;

return {
provide: CONFIG_VALUES_TOKEN,
useFactory: async (factory: ConfigModuleOptionsFactory<T>) => {
const definition = await factory.createConfigDefinition();
return definition.parse(process.env);
},
inject: [factoryToken],
};
}

// ── forRoot (global) ───────────────────────────────────────────────────────

/**
Expand Down
25 changes: 25 additions & 0 deletions src/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,29 @@ export class ConfigService<
// the shape matches ConfigOutput<TDef> exactly
return this._config[key as string] as ConfigOutput<TDef>[K];
}

/**
* Returns the entire validated config object as a readonly snapshot.
*
* Useful for:
* - Passing the full config to a third-party initialiser that expects a plain object
* - Debug logging (e.g. `console.log(config.getAll())`)
* - Spreading into another object when all keys are needed
*
* The returned object is the same frozen reference produced by `definition.parse()` —
* mutating it will throw in strict mode.
*
* @returns The complete, fully-typed validated config object.
*
* @example
* ```typescript
* const all = this.config.getAll();
* // all.PORT → number
* // all.DATABASE_URL → string
* ```
*/
getAll(): Readonly<ConfigOutput<TDef>> {
// Cast is safe — same guarantee as get()
return this._config as unknown as Readonly<ConfigOutput<TDef>>;
}
}
Loading
Loading