CtroEnv
ctroenvType-Safe Environment Variables
Getting StartedQuick StartCore Concepts
defineEnv()string()number()boolean()semver()pick()ip(), ipv4(), ipv6()uuid(), guid()Chainable MethodsRefinementsError HandlingSchema CompositionSecurityCustom ValidatorswatchEnv()
CLI Overviewctroenv validatectroenv generatectroenv checkctroenv docsctroenv initCLI Configuration
Node AdapterVite AdapterNext.js Adapter
Migration from t3-envMigration from envalidMigration from dotenv

Chainable Methods

Common chainable methods shared across all validators: optional, default, describe.

  1. Docs
  2. Core API

Chainable Methods

Every validator provides a set of common methods for controlling behavior, metadata, and value handling. These are available on all validators: string(), number(), boolean(), pick(), semver(), ip(), uuid(), and custom validators.

Signature

interface ChainableMethods<T> {
  optional(): Validator<T | undefined> & ChainableMethods<T | undefined>
  default(value: T): Validator<T> & ChainableMethods<T>
  describe(text: string): Validator<T> & ChainableMethods<T>
  secret(): Validator<T> & ChainableMethods<T>
  validate(
    fn: (value: T, context: { key: string; path: readonly string[] }) => string | undefined,
  ): Validator<T> & ChainableMethods<T>
}

.optional()

Marks the variable as optional. When the environment variable is not set, the value will be undefined instead of causing a missing-required error.

const env = defineEnv({
  CACHE_TTL: number().optional(),
})
// env.CACHE_TTL: number | undefined
// If CACHE_TTL is not set, env.CACHE_TTL is undefined
// If CACHE_TTL is set to "3000", env.CACHE_TTL is 3000

TypeScript infers T | undefined, so you must check for undefined before using the value:

if (env.CACHE_TTL !== undefined) {
  // env.CACHE_TTL is now number
}

.default(value)

Provides a fallback value when the environment variable is not set. Overrides any .optional() setting — the value is always present.

const env = defineEnv({
  PORT: number().port().default(3000),
})
// env.PORT: number
// If PORT is not set, env.PORT is 3000
// If PORT is set to "4000", env.PORT is 4000

TypeScript infers T (non-nullable) because the default ensures the value always exists.

In development mode (NODE_ENV=development), the default value is validated against the validator. A warning is logged to the console if the default fails validation — for example, a string().url().default("not-a-url") would warn.

.describe(text)

Attaches a human-readable description to the validator. This description appears in:

  • Error messages: "Missing required environment variable: DATABASE_URL -- Primary database connection"
  • CLI output: Shown in the ctroenv docs command output
  • Generated .env.example files: Displayed as comments
const env = defineEnv({
  DATABASE_URL: string().url().describe("Primary PostgreSQL database connection string"),
  JWT_SECRET: string().secret().describe("Secret key for JWT token signing"),
})

.secret()

Marks the variable as sensitive. Secret values are:

  • Masked at runtime: Reading env.JWT_SECRET returns "********" instead of the real value
  • Masked in CLI output: Shown as •••••••• instead of the actual value
  • Masked in errors: Error messages never contain the raw secret value
  • Masked in serialization: JSON.stringify(env) replaces secrets with "********"
  • Masked in generated docs: The value is excluded from .env.example content
const env = defineEnv({
  JWT_SECRET: string().secret(),
  API_KEY: string().secret().describe("Third-party API key"),
})

console.log(env.JWT_SECRET) // "********"

Accessing raw values with env.meta

To retrieve the actual secret value, use the .meta API:

env.meta.get("JWT_SECRET")     // "my-real-secret-token"
env.meta.has("JWT_SECRET")     // true
env.meta.keys()                // ["JWT_SECRET", "API_KEY"]
env.meta.toJSON()              // { JWT_SECRET: "my-real-secret-token", API_KEY: "..." }

The meta object is non-enumerable — it won't appear in Object.keys(), for...in, or JSON.stringify(). Access it explicitly when you need the raw value.

.validate(fn)

Adds a custom validation function. The function receives the parsed value and a context object. Return undefined to pass, or a string error message to fail.

const env = defineEnv({
  API_KEY: string().validate((value, { key }) => {
    if (!value.startsWith("sk_")) {
      return `${key} must start with "sk_"`
    }
  }),
})

The context provides:

FieldTypeDescription
keystringThe environment variable name
pathreadonly string[]The full path to the value (for nested schemas)

This enables conditional validation based on other values:

const schema = {
  NODE_ENV: pick(["dev", "prod"]),
  DATABASE_URL: string().url(),
}

const env = defineEnv(schema)

However, validators cannot reference env during definition (it doesn't exist yet). For cross-field validation, use a wrapper function:

function createSchema() {
  const nodeEnv = pick(["dev", "prod"])
  return {
    NODE_ENV: nodeEnv,
    DATABASE_URL: string().url().validate((value) => {
      // Read the raw env value at runtime
      const raw = process.env.NODE_ENV ?? "dev"
      if (raw === "prod" && !value.includes("replica")) {
        return "Production should use a replica URL"
      }
    }),
  }
}

const env = defineEnv(createSchema())

Note: The validate function runs after type checking and other refinements. The value is guaranteed to be of the correct type.

Chain Order

Validator-specific refinements (.url(), .min(), .email(), .hostname(), etc.) must be chained before .secret(), .optional(), or .describe(). This is because .secret() and .optional() return a generic Validator & ChainableMethods wrapper that no longer has type-specific methods like .min() or .url().

// ✅ Correct — refinements first, then modifiers
string().url().min(1).secret()
number().int().positive().optional()

// ❌ Wrong — .min() won't exist on the generic wrapper
string().secret().url().min(1)
//          ~~~~~~~ ~~~~~~~ ~~~~~~
//          Error: Property 'url' does not exist on type 'Validator<string> & ChainableMethods<string>'

The same applies to all validators with type-specific refinements.

How is this guide?

Edit on GitHub

Last updated on Jun 30, 2026

Previousuuid(), guid()NextRefinements

On this page

Signature.optional().default(value).describe(text).secret()Accessing raw values with `env.meta`.validate(fn)Chain Order