deep-dives·2026-06-22·3 min read

TypeScript Inference Tricks with CtroEnv

How CtroEnv infers exact types from validators without manual type definitions.

Ctrotech·
guidetypescript
RSS Feed

TypeScript Inference Tricks with CtroEnv

CtroEnv infers the exact shape of your env object from the schema — no manual type definitions, no zod.infer, no as const boilerplate.

How It Works

The InferredValue type checks each validator's metadata to decide the output type:

type InferredValue<V extends Validator<unknown>> =
  V extends Validator<infer T>
    ? V["metadata"] extends { hasDefault: true }
      ? T                    // .default() → guaranteed non-null
      : V["metadata"] extends { optional: true }
        ? T | undefined      // .optional() → nullable
        : T                  // required → guaranteed non-null
    : never

This means the type system reads your validator's configuration at compile time:

const env = defineEnv({
  PORT: number().port().default(3000),         // hasDefault → number
  DB_URL: string().url(),                       // required  → string
  DEBUG: boolean().optional(),                  // optional  → boolean | undefined
})
//     ^? { readonly PORT: number; readonly DB_URL: string; readonly DEBUG: boolean | undefined }

Without Manual Types

Compare with the manual approach:

// Manual types — must keep in sync
interface Env {
  PORT: number
  DB_URL: string
  DEBUG?: boolean
}
const env = process.env as unknown as Env

// CtroEnv — types from schema
const env = defineEnv({
  PORT: number().port().default(3000),
  DB_URL: string().url(),
  DEBUG: boolean().optional(),
})

Add a new validator to the schema and the type updates automatically. No interface to edit.

pick() Creates Literal Unions

pick() with as const creates exact string literal unions:

const env = defineEnv({
  NODE_ENV: pick(["development", "production", "staging"] as const),
})
// env.NODE_ENV: "development" | "production" | "staging"

This enables exhaustive checking:

switch (env.NODE_ENV) {
  case "development": break
  case "production": break
  case "staging": break
  // TypeScript error if a case is missing
}

The as const Requirement

pick() needs as const to preserve literal types:

pick(["dev", "prod"])                // type: string
pick(["dev", "prod"] as const)       // type: "dev" | "prod"

Without as const, TypeScript widens the array to string[] and the inferred type becomes string.

Default Values Affect Types

.default(v) makes the type non-nullable:

PORT: number().optional()            // number | undefined
PORT: number().default(3000)         // number  — no undefined
PORT: number()                       // number  — required, guaranteed present

Composed Schemas Preserve Types

extendSchema() preserves the exact types of both schemas:

const base = defineSchema({
  NODE_ENV: pick(["dev", "prod"] as const),
})

const schema = extendSchema(base, {
  PORT: number().port().default(3000),
})

const env = defineEnv(schema)
// env.NODE_ENV: "dev" | "prod"
// env.PORT: number

InferredClientServerEnv

For Next.js adapters, InferredClientServerEnv merges server and client schemas into one flat type:

import { type ClientServerSchema, type InferredClientServerEnv } from "@ctroenv/core"

type Schema = ClientServerSchema
// { client: SchemaDefinition, server: SchemaDefinition }

type Env = InferredClientServerEnv<Schema>
// { DATABASE_URL: string; NEXT_PUBLIC_API_URL: string; ... }

Readonly by Default

The returned env object is typed as readonly:

const env = defineEnv({ PORT: number() })
env.PORT = 4000
// ❌ Cannot assign to 'PORT' because it is a read-only property

This applies at the type level. The runtime also enforces it via the Proxy.

Checking Inferred Types

Use satisfies to validate your schema without losing inference:

import { type ClientServerSchema } from "@ctroenv/core"

const schema = {
  server: { DATABASE_URL: string().url() },
  client: { NEXT_PUBLIC_URL: string().url() },
} satisfies ClientServerSchema
// Full type inference preserved
More deep-dives posts
Previous postError Handling Patterns with CtroEnvNext postEnvironment Variables in Next.js

On this page

How It WorksWithout Manual Typespick() Creates Literal UnionsThe `as const` RequirementDefault Values Affect TypesComposed Schemas Preserve TypesInferredClientServerEnvReadonly by DefaultChecking Inferred Types