@studnicky/config
Configuration validation utilities and type guards.
Install
pnpm add @studnicky/configRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
Validate required fields and check types in a configuration object. All assertion methods skip undefined/null values, and throw ConfigurationError on failure:
import { ConfigurationError, ConfigValidation, Guard } from '../src/index.js';
import { ValidateConfigFixtures } from './fixtures/ValidateConfigFixtures.js';
const { config } = ValidateConfigFixtures;
// Valid config — all assertions pass silently
ConfigValidation.assertNoUnknownKeys(config, ValidateConfigFixtures.knownKeys);
ConfigValidation.assertString(config.host, 'host');
ConfigValidation.assertNumber(config.port, 'port');
ConfigValidation.assertBoolean(config.debug, 'debug');
ConfigValidation.assertPositive(config.maxRetries, 'maxRetries');
console.log('Config validated:', config);
// Type guards
console.log('isPositiveInteger(5):', Guard.isPositiveInteger(5));
console.log('isPositiveInteger(0):', Guard.isPositiveInteger(0));
console.log('isObject({}):', Guard.isObject({ 'key': 'value' }));
console.log('isObject(null):', Guard.isObject(null));
console.log('isNonNegativeInteger(0):', Guard.isNonNegativeInteger(0));
console.log('isFunction(fn):', Guard.isFunction(() => {}));
// Null/undefined inputs are skipped — no error thrown
ConfigValidation.assertString(undefined, 'optionalField');
ConfigValidation.assertNumber(null, 'optionalCount');
ConfigValidation.assertBoolean(undefined, 'optionalFlag');
// Invalid input throws ConfigurationError
let caughtString: ConfigurationError | undefined;
try {
ConfigValidation.assertString(42, 'host');
} catch (err) {
if (err instanceof ConfigurationError) {
caughtString = err;
}
}
let caughtNumber: ConfigurationError | undefined;
try {
ConfigValidation.assertNumber('not-a-number', 'port');
} catch (err) {
if (err instanceof ConfigurationError) {
caughtNumber = err;
}
}
let caughtUnknownKey: ConfigurationError | undefined;
try {
ConfigValidation.assertNoUnknownKeys({ 'host': 'localhost', 'unknownKey': true }, ValidateConfigFixtures.knownKeys);
} catch (err) {
if (err instanceof ConfigurationError) {
caughtUnknownKey = err;
}
}
console.log('assertString(42) threw:', caughtString?.message);
console.log('assertNumber("x") threw:', caughtNumber?.message);
console.log('assertNoUnknownKeys threw:', caughtUnknownKey?.message);Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/config | ConfigValidation, Guard, ConfigurationError, ensureError |
@studnicky/config/errors | ConfigurationError, ensureError |
@studnicky/config/validation | ConfigValidation, Guard |
Try it
/** validate-config — asserting required fields, type guards, and null-skip behaviour. Run: npx tsx packages/config/examples/validate-config.ts */
import assert from 'node:assert/strict';
// #region usage
import { ConfigurationError, ConfigValidation, Guard } from '../src/index.js';
import { ValidateConfigFixtures } from './fixtures/ValidateConfigFixtures.js';
const { config } = ValidateConfigFixtures;
// Valid config — all assertions pass silently
ConfigValidation.assertNoUnknownKeys(config, ValidateConfigFixtures.knownKeys);
ConfigValidation.assertString(config.host, 'host');
ConfigValidation.assertNumber(config.port, 'port');
ConfigValidation.assertBoolean(config.debug, 'debug');
ConfigValidation.assertPositive(config.maxRetries, 'maxRetries');
console.log('Config validated:', config);
// Type guards
console.log('isPositiveInteger(5):', Guard.isPositiveInteger(5));
console.log('isPositiveInteger(0):', Guard.isPositiveInteger(0));
console.log('isObject({}):', Guard.isObject({ 'key': 'value' }));
console.log('isObject(null):', Guard.isObject(null));
console.log('isNonNegativeInteger(0):', Guard.isNonNegativeInteger(0));
console.log('isFunction(fn):', Guard.isFunction(() => {}));
// Null/undefined inputs are skipped — no error thrown
ConfigValidation.assertString(undefined, 'optionalField');
ConfigValidation.assertNumber(null, 'optionalCount');
ConfigValidation.assertBoolean(undefined, 'optionalFlag');
// Invalid input throws ConfigurationError
let caughtString: ConfigurationError | undefined;
try {
ConfigValidation.assertString(42, 'host');
} catch (err) {
if (err instanceof ConfigurationError) {
caughtString = err;
}
}
let caughtNumber: ConfigurationError | undefined;
try {
ConfigValidation.assertNumber('not-a-number', 'port');
} catch (err) {
if (err instanceof ConfigurationError) {
caughtNumber = err;
}
}
let caughtUnknownKey: ConfigurationError | undefined;
try {
ConfigValidation.assertNoUnknownKeys({ 'host': 'localhost', 'unknownKey': true }, ValidateConfigFixtures.knownKeys);
} catch (err) {
if (err instanceof ConfigurationError) {
caughtUnknownKey = err;
}
}
console.log('assertString(42) threw:', caughtString?.message);
console.log('assertNumber("x") threw:', caughtNumber?.message);
console.log('assertNoUnknownKeys threw:', caughtUnknownKey?.message);
// #endregion usage
assert.equal(Guard.isPositiveInteger(5), true);
assert.equal(Guard.isPositiveInteger(0), false);
assert.equal(Guard.isPositiveInteger(-1), false);
assert.equal(Guard.isPositiveInteger(1.5), false);
assert.equal(Guard.isObject({ 'key': 'value' }), true);
assert.equal(Guard.isObject(null), false);
assert.equal(Guard.isObject('string'), false);
assert.equal(Guard.isObject([]), false);
assert.equal(Guard.isNonNegativeInteger(0), true);
assert.equal(Guard.isNonNegativeInteger(10), true);
assert.equal(Guard.isNonNegativeInteger(-1), false);
assert.equal(Guard.isFunction(() => {}), true);
assert.equal(Guard.isFunction('notafn'), false);
assert.ok(caughtString instanceof ConfigurationError, 'Expected ConfigurationError for string');
assert.equal(caughtString.message, 'host must be a string');
assert.equal(caughtString.code, 'config.invalid');
assert.ok(caughtNumber instanceof ConfigurationError, 'Expected ConfigurationError for number');
assert.equal(caughtNumber.message, 'port must be a number');
assert.ok(caughtUnknownKey instanceof ConfigurationError, 'Expected ConfigurationError for unknown key');
assert.ok(caughtUnknownKey.message.includes('unknownKey'));
console.log('validate-config: all assertions passed');
The output shows fields passing validation, guard results for isPositiveInteger/isObject/isFunction, and the ConfigurationError messages thrown for wrong types and unknown keys.
ensureError
ensureError converts any caught value to a typed Error. Use it to safely handle unknown catches without unsafe casts:
import { ensureError } from '@studnicky/config';
try {
await doWork();
} catch (thrown) {
const err = ensureError(thrown); // always an Error
log.error(err.message);
}Extending
ConfigValidation is a static class. Subclass it and override onValidationError to emit a domain-specific error type instead of ConfigurationError:
import { ConfigValidation } from '../src/index.js';
class AppConfigValidation extends ConfigValidation {
protected static override onValidationError(message: string): never {
throw new Error(`[app] ${message}`);
}
}
// Invalid string — custom prefix applied
let caughtString: Error | undefined;
try {
AppConfigValidation.assertString(123, 'apiKey');
} catch (err) {
if (err instanceof Error) {
caughtString = err;
}
}
// Invalid number — same override path
let caughtNumber: Error | undefined;
try {
AppConfigValidation.assertNumber('oops', 'timeout');
} catch (err) {
if (err instanceof Error) {
caughtNumber = err;
}
}
// Invalid boolean — same override path
let caughtBoolean: Error | undefined;
try {
AppConfigValidation.assertBoolean(1, 'enabled');
} catch (err) {
if (err instanceof Error) {
caughtBoolean = err;
}
}
// Valid inputs pass silently
AppConfigValidation.assertString('valid', 'name');
AppConfigValidation.assertNumber(42, 'count');
console.log('assertString(123) threw:', caughtString?.message);
console.log('assertNumber("oops") threw:', caughtNumber?.message);
console.log('assertBoolean(1) threw:', caughtBoolean?.message);Clamping
ClampedConfig is the soft-correction sibling to ConfigValidation's hard-fail assertions: given a flat config object and a declarative table of {min, max, reason} per numeric field, apply returns a new object with out-of-range numeric fields clamped into range instead of throwing. Fields not present in the rule table, not numeric, or already in range are copied through unchanged; the input is never mutated.
import { ClampedConfig, type ClampRuleType } from '@studnicky/config';
interface WorkerConfig {
timeoutMs: number;
concurrency: number;
}
const rules: Readonly<Record<string, ClampRuleType>> = {
timeoutMs: { min: 100, max: 5000, reason: 'timeout must stay within safe bounds' },
concurrency: { min: 1, max: 8, reason: 'concurrency must stay within pool capacity' },
};
const raw: WorkerConfig = { timeoutMs: 10, concurrency: 4 };
const clamped = ClampedConfig.apply(raw, rules);
// clamped.timeoutMs === 100, clamped.concurrency === 4, raw is unchangedOverride the protected onClamp static method to observe clamp events — logging is the caller's responsibility, ClampedConfig has no dependency on any logging package:
import { ClampedConfig, type ClampEventType } from '@studnicky/config';
class LoggingClampedConfig extends ClampedConfig {
protected static override onClamp(event: ClampEventType): void {
console.warn(`[config] clamped ${event.field}: ${event.raw} -> ${event.clamped} (${event.reason})`);
}
}
LoggingClampedConfig.apply(raw, rules);