Most TypeScript projects treat environment variables like second-class citizens. They're string | undefined everywhere, asserted with ! and parsed with parseInt(). TypeScript can't help because process.env is typed as Record<string, string | undefined>.
Schema-based validation fixes this. But most solutions bring zod, which adds 50 KB to your bundle. CtroEnv does it with zero dependencies and 6.5 KB gzipped.
How Inference Works
The type system reads each validator's configuration at compile time:
type InferredValue<V> =
V extends Validator<infer T>
? V["metadata"] extends { hasDefault: true }
? T // .default() → non-nullable
: V["metadata"] extends { optional: true }
? T | undefined // .optional() → nullable
: T // required → guaranteed present
: never
This means the schema defines the type:
const env = defineEnv({
PORT: number().port().default(3000),
// ^? number — default makes it always present
DB_URL: string().url(),
// ^? string — required
DEBUG: boolean().optional(),
// ^? boolean | undefined — optional
NODE_ENV: pick(["dev", "prod", "staging"] as const),
// ^? "dev" | "prod" | "staging" — exact union
})
No interface Env { ... }. No z.infer<typeof Schema>. Add a new validator, and the type updates automatically.
Default vs Optional vs Required
The three states and their types:
| Declaration | Type | Runtime behavior |
|---|---|---|
string() |
string |
Required — throws if missing |
string().optional() |
`string \ | undefined` |
string().default("x") |
string |
Falls back to "x"
|
string().optional().default("x") |
string |
Default overrides optional |
TypeScript reflects this exactly. Optional gives you | undefined. Default removes it.
The as const Requirement
pick() needs as const to preserve literal types:
pick(["dev", "prod"]) // type: string — widened
pick(["dev", "prod"] as const) // type: "dev" | "prod" — exact union
Without as const, TypeScript widens the array to string[] and you lose the union.
Exhaustive Checking
With exact literal types, you get exhaustive checking:
switch (env.NODE_ENV) {
case "dev": break
case "prod": break
case "staging": break
// TypeScript error if you forget a case — and you can't match "production"
// because it's not in the union
}
The Chain Order Gotcha
Type-specific methods (.url(), .email(), .min()) must come before chainable methods (.secret(), .optional(), .describe()):
string().url().secret() // ✅ correct
string().secret().url() // ❌ — .url() doesn't exist after .secret()
Reason: .secret() returns a generic Validator & ChainableMethods wrapper. The type-specific methods like .url() only exist on StringValidator. Once you call a chainable method, those are gone.
Quick reference:
// ✅ Correct
string().min(1).max(255).url().secret()
number().int().positive().default(42)
pick(["a", "b"] as const).optional()
boolean().default(false)
// ❌ Wrong
string().secret().url() // .url() lost
number().optional().int() // .int() lost
pick(), boolean(), semver(), ip(), uuid() have no type-specific refinements, so chain order doesn't matter for them.
Composing Schemas
defineSchema() + extendSchema() preserve full types through composition:
import { defineSchema, extendSchema } from "@ctroenv/core"
const base = defineSchema({
NODE_ENV: pick(["dev", "prod"] as const).default("dev"),
})
const schema = extendSchema(base, {
PORT: number().port().default(3000),
})
const env = defineEnv(schema)
// env.NODE_ENV: "dev" | "prod"
// env.PORT: number
The composed type merges both schemas. Extension keys override base keys — with a dev-mode warning on conflicts.
Compared to Zod
// zod — 50 KB dependency
import { z } from "zod"
const Schema = z.object({
PORT: z.coerce.number().min(1).max(65535).default(3000),
DB_URL: z.string().url(),
})
type Env = z.infer<typeof Schema>
// CtroEnv — 6.5 KB, zero deps
import { defineEnv, string, number } from "@ctroenv/core"
const env = defineEnv({
PORT: number().port().default(3000),
DB_URL: string().url(),
})
// ^? { PORT: number; DB_URL: string } — inferred automatically
Same validation power. No manual type extraction. No extra dependency.
npm install @ctroenv/core
Previous: Why Your .env File Is Lying to You
Next: Framework-Specific Env Patterns