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

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
  maskWith?: string
}
OptionTypeDefaultDescription
sourceEnvSource | RecordAuto-detectedThe environment source to read from
prefixstringundefinedPrefix added to each key when looking up in the source
maskWithstring"********"Custom mask string for secret values

source

Controls where environment variables are read from. If omitted, detectSource() checks runtimes in this order: import.meta.env → Deno.env → Bun.env → process.env.

// Auto-detect
defineEnv(schema)

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

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

// Cloudflare Workers
defineEnv(schema, { source: workersSource(env) })

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

maskWith

Overrides the default mask string "********" used for .secret() values:

const env = defineEnv(schema, { maskWith: "***" })
env.JWT_SECRET // "***"

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.

Known Limitations

structuredClone(env) throws DataCloneError. This is a V8 limitation on Proxy objects and cannot be fixed in userland. Use JSON.parse(JSON.stringify(env)) as a workaround for serialization.

How is this guide?

Edit on GitHub

Last updated on Jun 30, 2026

PreviousCore ConceptsNextstring()

On this page

SignatureParametersschemaopts (optional)Return ValueOptional vs RequiredError HandlingType InferenceKnown Limitations