Error Handling Patterns with CtroEnv
Ctrotech
guidepatterns
Error Handling Patterns with CtroEnv
The basic try/catch covers the happy path. Real apps need more: graceful degradation, environment-specific rules, and integration with error monitoring.
Basic Try/Catch
import { CtroEnvError, formatErrors } from "@ctroenv/core"
try {
const env = defineEnv(schema)
} catch (e) {
if (e instanceof CtroEnvError) {
process.stderr.write(formatErrors(e.errors))
process.exit(1)
}
throw e
}
Graceful Degradation
Some apps should start even when env vars are missing — with reduced functionality:
function createEnv(schema: SchemaDefinition, source: EnvSource) {
try {
return defineEnv(schema, { source })
} catch (e) {
if (e instanceof CtroEnvError) {
console.warn("Running with partial env:", e.errors.map(e => e.key))
}
// Return a partial env with fallbacks
return createFallbackEnv(schema, source)
}
}
Or use .optional() and .default() for variables that aren't critical:
const schema = {
DATABASE_URL: string().url(), // required
REDIS_URL: string().url().optional(), // optional — no Redis? degrade gracefully
CACHE_TTL: number().positive().default(300), // has default
FEATURE_FLAGS: string().optional().default(""), // optional with empty default
}
Environment-Specific Requirements
Different environments need different validation rules:
function createSchema(nodeEnv: string) {
const isDev = nodeEnv === "development"
return {
DATABASE_URL: string().url(),
// Dev can use a mock email service
EMAIL_SERVICE: isDev
? string().default("mock")
: pick(["sendgrid", "ses", "mailgun"] as const),
// Dev has looser password requirements
JWT_SECRET: isDev ? string().min(16) : string().min(32),
// Dev can use self-signed certs
NODE_TLS_REJECT_UNAUTHORIZED: isDev
? pick(["0", "1"] as const).default("0")
: pick(["1"] as const).default("1"),
}
}
const env = defineEnv(createSchema(process.env.NODE_ENV ?? "development"))
Per-Error Handling
Handle specific error codes differently:
import { CtroEnvError } from "@ctroenv/core"
try {
const env = defineEnv(schema)
} catch (e) {
if (e instanceof CtroEnvError) {
for (const err of e.errors) {
switch (err.code) {
case "missing_required":
console.error(`Add ${err.key} to your .env file`)
break
case "type_mismatch":
console.error(`${err.key} should be a ${err.message}`)
break
case "invalid_value":
console.error(`${err.key}: ${err.message}`)
break
case "validation_failed":
console.error(`${err.key}: ${err.message}`)
break
}
}
process.exit(1)
}
throw e
}
Integration with Sentry
import * as Sentry from "@sentry/node"
try {
const env = defineEnv(schema)
} catch (e) {
if (e instanceof CtroEnvError) {
Sentry.captureMessage("Environment validation failed", {
level: "fatal",
extra: { errors: e.errors },
})
}
throw e
}
Startup vs Runtime Validation
Validate critical vars at startup, validate optional ones when first used:
// Startup — fail fast for critical vars
const env = defineEnv({
DATABASE_URL: string().url(),
JWT_SECRET: string().min(32),
})
// Runtime — validate optional features when accessed
function getRedisConfig() {
if (process.env.REDIS_URL) {
return defineEnv(
{ REDIS_URL: string().url() },
{ source: { REDIS_URL: process.env.REDIS_URL } },
).REDIS_URL
}
return null
}
Multiple Sources with Priority
Combine file-based env with system env:
import { loadEnv } from "@ctroenv/node"
const fileSource = loadEnv({ path: "../.." })
const systemSource = { get: (k: string) => process.env[k] }
const env = defineEnv(schema, {
source: {
get(key: string) {
return fileSource.get(key) ?? systemSource.get(key)
},
},
})
This pattern lets you commit a .env with safe defaults while allowing production overrides through system environment variables.
Testing Error Paths
import { CtroEnvError } from "@ctroenv/core"
it("handles missing vars gracefully", () => {
try {
defineEnv({ DB_URL: string().url() }, { source: { get: () => undefined } })
} catch (e) {
expect(e).toBeInstanceOf(CtroEnvError)
expect((e as CtroEnvError).errors[0].code).toBe("missing_required")
}
})