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

defineEnv()

The main entry point for creating a type-safe environment variable schema.

  1. Docs
  2. Core API

defineEnv()

The core function that validates environment variables against a schema and returns a typed environment object.

Signature

function defineEnv<T extends SchemaDefinition>(
  schema: T,
  opts?: DefineEnvOptions,
): Readonly<InferredEnv<T>>

Parameters

schema

A schema definition object where keys are environment variable names and values are validators:

const schema = {
  DATABASE_URL: string().url(),
  PORT: number().port().default(3000),
}

opts (optional)

interface DefineEnvOptions {
  source?: EnvSource | Record<string, string | undefined>
  prefix?: string
}
OptionTypeDefaultDescription
sourceEnvSource | RecordAuto-detectedThe environment source to read from
prefixstringundefinedPrefix added to each key when looking up in the source

source

Controls where environment variables are read from. If omitted, detectSource() checks import.meta.env first, then falls back to process.env.

// Auto-detect
defineEnv(schema)

// Explicit Node.js source (from @ctroenv/node)
defineEnv(schema, { source: nodeSource() })

// From .env files
defineEnv(schema, { source: loadEnv() })

// Plain object (testing)
defineEnv(schema, {
  source: { DATABASE_URL: "postgresql://localhost:5432/db" },
})

prefix

When set, all key lookups are prefixed. For example, with prefix: "MY_APP_", DATABASE_URL in the schema looks up MY_APP_DATABASE_URL in the source.

const env = defineEnv(schema, { prefix: "MY_APP_" })
// Looks up MY_APP_DATABASE_URL, MY_APP_PORT, etc.

Return Value

Returns a read-only EnvResult<T> object. TypeScript infers the type from the schema:

const env = defineEnv({
  DATABASE_URL: string().url(),
  PORT: number().port().default(3000),
  DEBUG: boolean().optional(),
  NODE_ENV: pick(["dev", "prod"]),
})

// TypeScript infers:
// env.DATABASE_URL → string
// env.PORT → number
// env.DEBUG → boolean | undefined
// env.NODE_ENV → "dev" | "prod"

The object is always wrapped in a Proxy that:

  • Masks secret value reads with "********"
  • Exposes raw values via env.meta.get("KEY")
  • Prevents mutation (set, delete)
  • Masks secrets in JSON.stringify()

Optional vs Required

  • Required (default): The value is guaranteed to exist. Type is T (non-nullable).
  • .optional(): Type is T | undefined.
  • .default(value): Type is T (non-nullable), falls back to the default when missing.

Error Handling

Throws CtroEnvError if one or more variables are missing or invalid:

try {
  const env = defineEnv(schema)
} catch (e) {
  if (e instanceof CtroEnvError) {
    for (const err of e.errors) {
      console.error(`${err.key}: ${err.message}`)
    }
    // Or use formatErrors() for a formatted CLI output
    process.stderr.write(formatErrors(e.errors))
  }
}

See Error Handling for details on all error types.

Type Inference

defineEnv() uses TypeScript's type system to infer the exact shape of the returned object:

  • string() → string
  • number() → number
  • boolean() → boolean
  • pick(["a", "b"]) → "a" | "b"
  • .optional() → adds | undefined
  • .default(v) → removes | undefined (value always exists)

The inferred type is correctly reflected in IDE autocomplete and type checking.

How is this guide?

Edit on GitHub

Last updated on Jun 24, 2026

PreviousCore ConceptsNextstring()

On this page

SignatureParametersschemaopts (optional)Return ValueOptional vs RequiredError HandlingType Inference