Type-Safe Env Vars Without Zod

typescript dev.to

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Same validation power. No manual type extraction. No extra dependency.

npm install @ctroenv/core
Enter fullscreen mode Exit fullscreen mode

Links: GitHub · Docs · npm

Previous: Why Your .env File Is Lying to You
Next: Framework-Specific Env Patterns

Source: dev.to

arrow_back Back to Tutorials