@ciscode/config-kit
v0.3.1
Published
Typed env config for NestJS with Zod validation at startup. Fails fast on misconfiguration. Per-module namespace injection. Most foundational backend package.
Readme
@ciscode/config-kit
Typed, Zod-based environment configuration for NestJS.
Define your env shape once with a Zod schema — ConfigKit validates process.env at startup, fails loudly on misconfiguration, and gives you fully-typed, undefined-free config throughout your app.
Why config-kit?
| Problem with @nestjs/config | What config-kit does |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| config.get('PORT') returns string \| undefined | config.get('PORT') returns number (inferred from Zod schema) |
| Validation is optional and buried in code | App fails to start if any env var is wrong or missing |
| Feature modules can't own their config slice | defineNamespace() gives each module its own typed scope |
📦 Installation
npm install @ciscode/config-kit zod @nestjs/common @nestjs/corePeer dependencies (install alongside this package):
| Package | Version |
| ---------------- | -------------- |
| @nestjs/common | ^10 \|\| ^11 |
| @nestjs/core | ^10 \|\| ^11 |
| zod | ^3 \|\| ^4 |
Quick Start
1. Define your config shape with defineConfig
// src/app.config.ts
import { defineConfig } from "@ciscode/config-kit";
import { z } from "zod";
export const appConfig = defineConfig(
z.object({
// z.coerce.number() converts the env string "3000" → number 3000
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"),
}),
);2. Register globally with ConfigModule.forRoot
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@ciscode/config-kit";
import { appConfig } from "./app.config";
@Module({
imports: [
// Validates process.env at startup — app never boots with bad config
ConfigModule.forRoot(appConfig),
],
})
export class AppModule {}3. Inject ConfigService for typed access
// src/server.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@ciscode/config-kit";
import { appConfig } from "./app.config";
@Injectable()
export class ServerService {
constructor(
// Pass `typeof appConfig` so TypeScript knows the exact schema shape
private readonly config: ConfigService<typeof appConfig>,
) {}
getPort(): number {
// Returns number — not string, not string|undefined
return this.config.get("PORT");
}
getDatabaseUrl(): string {
return this.config.get("DATABASE_URL");
}
}Async registration
Use registerAsync when the schema depends on another provider (e.g. a secrets vault):
ConfigModule.registerAsync({
imports: [VaultModule],
inject: [VaultService],
useFactory: async (vault: VaultService) => {
const secret = await vault.getSecret("JWT_SECRET");
return defineConfig(
z.object({
JWT_SECRET: z.string().min(32).default(secret),
PORT: z.coerce.number().default(3000),
}),
);
},
});Per-module config namespacing with defineNamespace
Feature modules can own and validate their own config slice independently, without polluting the root schema.
1. Define the namespace
// src/auth/auth.config.ts
import { defineNamespace } from "@ciscode/config-kit";
import { z } from "zod";
export const authConfig = defineNamespace(
"auth",
z.object({
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default("7d"),
}),
);2. Register in the feature module
// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { authConfig } from "./auth.config";
import { AuthService } from "./auth.service";
@Module({
// authConfig.asProvider() validates this slice at startup and registers the DI token
providers: [authConfig.asProvider(), AuthService],
})
export class AuthModule {}3. Inject with @InjectConfig
// src/auth/auth.service.ts
import { Injectable } from "@nestjs/common";
import { InjectConfig } from "@ciscode/config-kit";
import { z } from "zod";
import { authConfig } from "./auth.config";
@Injectable()
export class AuthService {
constructor(
// 'auth' matches the namespace name in defineNamespace('auth', ...)
@InjectConfig("auth")
private readonly cfg: z.output<typeof authConfig.definition.schema>,
) {}
getSecret(): string {
return this.cfg.JWT_SECRET; // string — never undefined
}
}Note:
AuthModulemust importConfigModule(or be in an app that doesConfigModule.forRoot()) so theNAMESPACE_REGISTRY_TOKENis available.
Startup failure example
If any required env var is missing or invalid, ConfigKit throws ConfigValidationError before the app finishes booting:
ConfigValidationError: Config validation failed:
• DATABASE_URL: Invalid url
• JWT_SECRET: String must contain at least 32 character(s)You can catch this in main.ts for custom formatting:
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { ConfigValidationError } from "@ciscode/config-kit";
import { AppModule } from "./app.module";
async function bootstrap() {
try {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
} catch (err) {
if (err instanceof ConfigValidationError) {
console.error("❌ Invalid configuration:\n", err.message);
// err.fields is a ZodIssue[] — inspect programmatically if needed
process.exit(1);
}
throw err;
}
}
bootstrap();API Reference
defineConfig(schema)
Declares the env shape. Returns a ConfigDefinition<T> — pass it to ConfigModule.
const appConfig = defineConfig(z.object({ PORT: z.coerce.number().default(3000) }));ConfigModule
| Method | Description |
| ------------------------------------- | ---------------------------------------------------------- |
| ConfigModule.forRoot(definition) | Global registration — ConfigService available everywhere |
| ConfigModule.register(definition) | Non-global registration — scoped to the importing module |
| ConfigModule.registerAsync(options) | Async registration with useFactory / inject |
ConfigService<TDef>
| Method | Returns |
| ----------------- | --------------------------------------------------------------------- |
| config.get(key) | Zod output type for key — never undefined unless schema allows it |
defineNamespace(namespace, schema)
Scoped config for feature modules. Returns NamespacedConfig<T>.
| Member | Description |
| ------------------------------- | ------------------------------------------------------------- |
| namespacedConfig.asProvider() | NestJS Provider — add to providers in your feature module |
| @InjectConfig(namespace) | Parameter decorator — injects the validated slice |
Errors
| Class | When thrown |
| ------------------------- | ------------------------------------------------------------------------------- |
| ConfigValidationError | One or more env vars fail Zod validation at startup. Has .fields: ZodIssue[]. |
| DuplicateNamespaceError | Same namespace name registered twice across the app. |
📝 Scripts
npm run build # Compile TypeScript to dist/
npm run typecheck # Type-check without emitting
npm test # Run Jest test suite
npm run test:cov # Tests + coverage report
npm run lint # ESLint (--max-warnings=0)
npm run format # Prettier check📄 License
MIT — see LICENSE
🤝 Contributing
See CONTRIBUTING.md
Made with ❤️ by CisCode
