Error Handling Patterns with CtroEnv

2026-06-22·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")
  }
})