CtroEnv
ctroenvType-Safe Environment Variables
Getting StartedQuick StartCore Concepts
defineEnv()string()number()boolean()pick()Chainable MethodsRefinementsError HandlingSchema Composition
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(), and pick().

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.

.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.

How is this guide?

Edit on GitHub

Last updated on Jun 24, 2026

Previouspick()NextRefinements

On this page

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