Chainable Methods
Common chainable methods shared across all validators: optional, default, describe.
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 docscommand output - Generated
.env.examplefiles: 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_SECRETreturns"********"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.examplecontent
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:
| Field | Type | Description |
|---|---|---|
key | string | The environment variable name |
path | readonly 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?
Last updated on Jun 30, 2026