Configuration Validation
A typed schema for your config is the cheapest reliability win you can take. Misconfiguration becomes a startup failure instead of a runtime crash hours later.
Defining a schema
import { z } from '@omnitron-dev/titan/validation';
export const AppConfigSchema = z.object({
port: z.number().int().min(1).max(65535),
environment: z.enum(['development', 'staging', 'production']),
database: z.object({
url: z.string().url(),
pool: z.object({
min: z.number().int().nonnegative().default(2),
max: z.number().int().positive().default(20),
}),
}),
cache: z.object({
tier: z.enum(['memory', 'redis']).default('memory'),
ttlMs: z.number().int().positive().default(60_000),
}).prefault({}),
features: z.object({
enableBilling: z.boolean().default(false),
}).prefault({}),
});
export type AppConfig = z.infer<typeof AppConfigSchema>;
.default({}) on a nested object drops the inner defaultsIn zod 4 .default(v) is an output default: when the key is
absent, v is returned as-is, without being parsed. So
z.object({ tier: …default('memory') }).default({}) yields {} — the
inner defaults never run, and config.cache.tier is undefined while
the schema reads as though it cannot be. TypeScript rejects it (the
default must satisfy the output type), which is worth not casting away.
.prefault({}) is the input default: the value is parsed, so the
inner defaults apply and you get { tier: 'memory', ttlMs: 60000 }.
Writing the object out in full — .default({ tier: 'memory', ttlMs: 60_000 }) — works too, at the cost of stating every default twice.
Wire it in. validateOnStartup is required — it has no default, so
a schema on its own is parsed by nothing and an invalid config loads in
silence:
ConfigModule.forRoot({
schema: AppConfigSchema,
validateOnStartup: true,
sources: [...],
})
What happens at boot
- Sources load and merge into a candidate config object.
- The candidate is parsed against the schema.
- On success: the typed result is frozen and exposed via
ConfigService. - On failure:
ConfigService.initialize()throwsErrors.badRequest('Configuration validation failed', { errors }), which surfaces as aTitanErrorwith codeBAD_REQUEST. There is noConfigValidationErrorclass.
Every invalid field is carried in details.errors, one entry per Zod
issue:
try {
await app.start();
} catch (err) {
if (err instanceof TitanError && err.code === ErrorCode.BAD_REQUEST) {
// [{ path, message, expected, received }, …]
console.error(err.details.errors);
}
}
[
{ path: 'port', message: 'Expected number, received string', expected: 'number', received: 'string' },
{ path: 'database.url', message: 'Required', expected: 'string', received: 'undefined' },
{ path: 'cache.tier', message: "Invalid enum value. Expected 'memory' | 'redis', received 'redos'" }
]
path is the Zod issue path joined with ., so a nested field reads
database.url. Use this to fail fast in CI: a config that doesn't
validate locally won't validate in production either.
This runs only when both validateOnStartup and schema are set, as
above.
Defaults
Schema-level defaults (z.number().default(60_000)) apply when no
source supplies the value. This is the right place for "sensible
default for development":
cache: z.object({
ttlMs: z.number().int().positive().default(60_000),
})
Don't put production defaults here — they should come from
production.yaml so they're explicit.
Coercion
@omnitron-dev/titan/validation exposes Zod with coercion enabled
for env-var sources. A z.number() field can read its value from
the string "3000" and coerce. Same for booleans, dates.
For stricter parsing (the source must provide the right type), use
z.number().strict() etc.
Per-section validation
You can validate just a slice of the config without loading the whole schema:
const cacheConfig = this.config.getTyped<CacheConfig>(CacheConfigSchema, 'cache');
// ^? CacheConfig (z.infer<typeof CacheConfigSchema>)
getTyped<T>(schema, path?) parses the value at path (or the whole
config when path is omitted) and throws if it doesn't match.
Useful in dynamic modules whose options are a subtree of the global config.
Branded types for safety
For values that should never accidentally interconvert (e.g. URLs vs
plain strings), use Zod's .brand:
const DatabaseUrl = z.string().url().brand<'DatabaseUrl'>();
type DatabaseUrl = z.infer<typeof DatabaseUrl>;
const Schema = z.object({
database: z.object({ url: DatabaseUrl }),
});
DatabaseUrl and string are now distinct types; you cannot pass a
plain string where a DatabaseUrl is expected. Catches "I forgot to
read the right config key" bugs at the type level.
Anti-patterns
z.unknown()everywhere. Defeats the point. Use specific types; if a value can be one of several shapes, use a union.- Validating in code instead of the schema. A runtime check
in
onInitis fine for state-derived invariants (the database schema matches my code), but not for config shape — that's what the schema is for. - Optional vs default for production values. Use defaults for development conveniences. Production values should be required so a missing one fails the boot.
→ Next: Hot Reload.