DEV Community

Cover image for Define Once, Trust Everywhere — CtroEnv Deep Dive
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

Define Once, Trust Everywhere — CtroEnv Deep Dive

The core API is just four validator factories. Here's how they work and when to use each one.

The Four Validators

string() — Text Values

The most common one. Accepts any string input with refinements for format validation:

import { string } from "@ctroenv/core"

const v = string()
  .url()                    // Must be a valid URL
  .email()                  // Must match email format
  .port()                   // Must be a port number (1-65535)
  .min(8)                   // Minimum length
  .max(256)                 // Maximum length
  .regex(/^[a-z]+$/, "Must be lowercase letters only")
Enter fullscreen mode Exit fullscreen mode

Each refinement returns a new StringValidator, so they chain in any order. Fail any refinement and you get an invalid_value error with a message like "Invalid URL" or "Must be at least 8 characters".

number() — Numeric Values

Accepts numbers or numeric strings. Coerces "3000" to 3000:

import { number } from "@ctroenv/core"

const v = number()
  .int()                    // Must be an integer
  .positive()               // Must be > 0
  .port()                   // Must be between 1-65535
  .min(1)                   // Minimum value
  .max(100)                 // Maximum value
Enter fullscreen mode Exit fullscreen mode

number() calls Number(input) on strings. It rejects empty strings and anything producing NaN. "3.14" parses fine but fails .int().

boolean() — True/False Values

Accepts booleans directly, coerces strings and numbers:

import { boolean } from "@ctroenv/core"

const v = boolean()
// Accepts: true, false
// Coerces: "true" -> true, "false" -> false, "1" -> true, "0" -> false
// Coerces: 1 -> true, 0 -> false
// Rejects: everything else
Enter fullscreen mode Exit fullscreen mode

Useful for feature flags:

# .env
ENABLE_METRICS=true
SHOW_BETA_FEATURES=0
Enter fullscreen mode Exit fullscreen mode
const env = defineEnv({
  ENABLE_METRICS: boolean().default(false),
  SHOW_BETA_FEATURES: boolean().default(false),
})
// env.ENABLE_METRICS === true
// env.SHOW_BETA_FEATURES === false
Enter fullscreen mode Exit fullscreen mode

pick() — Enum Values

Restricts to a set of allowed strings. The type is inferred as a union of literals:

import { pick } from "@ctroenv/core"

const env = defineEnv({
  NODE_ENV: pick(["development", "staging", "production"] as const).default("development"),
  LOG_LEVEL: pick(["debug", "info", "warn", "error"] as const).default("info"),
})

env.NODE_ENV // "development" | "staging" | "production"
env.LOG_LEVEL // "debug" | "info" | "warn" | "error"
Enter fullscreen mode Exit fullscreen mode

Typo "production" (missing the 'c')? The error includes a suggestion:

NODE_ENV  Did you mean 'production'?
Enter fullscreen mode Exit fullscreen mode

The as const assertion is critical — it keeps the literal types instead of widening to string.

Chainable Methods

Available on every validator, including custom ones built with createValidator().

.default(value) — Fallback When Not Set

const v = number().default(3000)
// If PORT is not set, env.PORT === 3000
Enter fullscreen mode Exit fullscreen mode

The inferred type is always the value type, never T | undefined:

const env = defineEnv({ PORT: number().port().default(3000) })
env.PORT // number — always present
Enter fullscreen mode Exit fullscreen mode

.optional() — Allow Undefined

const v = string().url().optional()

const env = defineEnv({
  SENTRY_DSN: string().url().optional().describe("Sentry error tracking DSN"),
})

env.SENTRY_DSN // string | undefined
Enter fullscreen mode Exit fullscreen mode

.describe(text) — Documentation Metadata

Attaches a description for error messages and auto-generated docs:

string().url().describe("PostgreSQL connection URL")

// Missing required environment variable: DATABASE_URL — PostgreSQL connection URL
Enter fullscreen mode Exit fullscreen mode

Descriptions also appear in ctroenv docs output and .env.example comments.

.secret() — Protect Sensitive Values

Flags a variable as sensitive. Values are masked (********) in CLI output and commented out in .env.example. The runtime value is still accessible normally.

string().min(32).secret().describe("JWT signing secret")
Enter fullscreen mode Exit fullscreen mode

Order matters: .min() must come before .secret(). The type-specific methods (min, max, url, email, port, regex) live on StringValidator and NumberValidator. Chainable methods (secret, optional, default, describe, validate) return Validator<T> & ChainableMethods<T>, which doesn't include the type-specific refinements.

.validate(fn) — Custom Inline Validation

For one-off rules that don't need a full custom validator:

const env = defineEnv({
  API_KEY: string().validate((value) => {
    if (!value.startsWith("sk_")) return "Must start with 'sk_'"
    if (value.length < 40) return "Must be at least 40 characters"
    return undefined // pass
  }),
})
Enter fullscreen mode Exit fullscreen mode

The function receives the parsed value and a context object. Return undefined for pass, or a string error message for failure.

Standalone Refinements

Each refinement on StringValidator and NumberValidator is also available as a standalone function:

import { string, number, url, port, min, max, email, regex, integer } from "@ctroenv/core"

// These are equivalent:
string().url()
url()(string())

string().min(8)
min(8)(string())

number().int()
integer()(number())

number().port()
port()(number())
Enter fullscreen mode Exit fullscreen mode

Useful for composing refinements from different validator types or building reusable validator factories.

The Error System

When defineEnv() encounters failures, it collects every error and throws a single CtroEnvError. Fail fast, but tell the developer everything wrong at once.

How Errors Are Structured

class ValidationError {
  key: string           // "DATABASE_URL"
  message: string       // "Invalid URL"
  code: ErrorCode       // "missing_required" | "type_mismatch" | "invalid_value" | ...
  value: unknown        // The original (invalid) value
  suggestion?: string   // "Did you mean 'production'?"
}
Enter fullscreen mode Exit fullscreen mode

Error Codes

Code When
missing_required Var not set and no default
type_mismatch Wrong type (string vs number, etc.)
invalid_value Failed a refinement (.url(), .min(), etc.)
validation_failed Custom .validate() returned an error
coercion_failed Could not coerce (string "foo" to number)

Formatted Output

formatErrors() produces grouped, colored terminal output:

 ● Missing required (2)

   DATABASE_URL  Add this variable to your .env file or set it in the environment.
   JWT_SECRET    Required — no default

 ✗ Invalid (1)

   CORS_ORIGIN
   Invalid URL
Enter fullscreen mode Exit fullscreen mode

It detects NO_COLOR, CI, and TERM=dumb — in CI, colors are stripped automatically.

Programmatic Error Handling

import { CtroEnvError } from "@ctroenv/core"

try {
  const env = defineEnv(schema)
} catch (e) {
  if (e instanceof CtroEnvError) {
    for (const err of e.errors) {
      console.error(`${err.key}: ${err.message}`)
      if (err.suggestion) console.error(`  -> ${err.suggestion}`)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Framework Adapters

Node.js — @ctroenv/node

import { nodeSource, loadEnv } from "@ctroenv/node"
import { defineEnv } from "@ctroenv/core"

// Just process.env
const env = defineEnv(schema, { source: nodeSource() })

// Load .env files with priority
const env = defineEnv(schema, { source: loadEnv() })
Enter fullscreen mode Exit fullscreen mode

loadEnv() reads .env -> .env.{NODE_ENV} -> .env.local, each overriding the previous.

Vite — @ctroenv/vite

Two integration points — a runtime source and a build plugin:

// vite.config.ts
import { defineConfig } from "vite"
import { ctroenvPlugin } from "@ctroenv/vite"

export default defineConfig({
  plugins: [
    ctroenvPlugin({ schema: "./src/schema.ts", failOnError: true }),
  ],
})
Enter fullscreen mode Exit fullscreen mode
// src/main.ts
import { defineEnv } from "@ctroenv/core"
import { viteSource } from "@ctroenv/vite"
import { schema } from "./schema"

const env = defineEnv(schema, { source: viteSource() })
Enter fullscreen mode Exit fullscreen mode

With failOnError: true, vite build fails immediately if any variable is missing or invalid.

Next.js — @ctroenv/nextjs

Next.js has a server/client split problem. Server code can access all env vars, but client code can only access NEXT_PUBLIC_* variables. CtroEnv handles this with a two-part schema:

import { defineEnv } from "@ctroenv/nextjs"

export const env = defineEnv({
  server: {
    DATABASE_URL: string().url(),
    JWT_SECRET: string().secret().min(32),
  },
  client: {
    NEXT_PUBLIC_API_URL: string().url(),
    NEXT_PUBLIC_APP_NAME: string().default("My App"),
  },
})
Enter fullscreen mode Exit fullscreen mode

On the server, both schemas validate. On the client, only client validates. Accessing a server-only key from client code throws:

Server-only environment variable "JWT_SECRET" is not accessible on the client.
Prefix it with NEXT_PUBLIC_ to expose it to the client bundle.
Enter fullscreen mode Exit fullscreen mode

Catches the most common Next.js security mistake without requiring separate env files.

The EnvSource Abstraction

Every adapter implements this interface:

interface EnvSource {
  get(key: string): string | undefined
}
Enter fullscreen mode Exit fullscreen mode
Source What it reads
detectSource() (default) process.env -> import.meta.env
nodeSource() process.env
loadEnv() .env files on disk
viteSource() import.meta.env
Next.js defineEnv() process.env

Pass any object matching this interface to defineEnv():

const env = defineEnv(schema, {
  source: {
    get(key) {
      if (key === "DATABASE_URL") return "postgres://localhost:5432/db"
      if (key === "PORT") return "4000"
      return undefined
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Or use objectSource() for flat objects:

import { objectSource } from "@ctroenv/core"

const source = objectSource({
  DATABASE_URL: "postgres://localhost:5432/db",
  PORT: "4000",
})
Enter fullscreen mode Exit fullscreen mode

What defineEnv() Returns

A deeply frozen object. Every property is read-only at runtime:

const env = defineEnv({ PORT: number().port().default(3000) })
env.PORT = 4000 // TypeError in strict mode
Enter fullscreen mode Exit fullscreen mode

Recursive freeze applies to nested objects inside validated values. Prevents accidental mutation of what should be immutable config.

Full API Surface

Export Kind Package
defineEnv Function @ctroenv/core
string, number, boolean, pick Factories @ctroenv/core
createValidator, applyChain Factories @ctroenv/core
detectSource, objectSource Functions @ctroenv/core
url, email, port, min, max, regex, integer Refinements @ctroenv/core
CtroEnvError, ValidationError, formatErrors Errors @ctroenv/core
nodeSource, loadEnv Functions @ctroenv/node
viteSource, ctroenvPlugin Function + Plugin @ctroenv/vite
defineEnv, withCtroEnv Functions @ctroenv/nextjs

Up next: Monorepo Environment Management at Scale — sharing schemas across packages, extending base configs, running validation in CI.

Resources: Docs · @ctroenv/core on npm · GitHub

Top comments (0)