TypeScript Inference Tricks with CtroEnv

2026-06-22·Ctrotech·
guidetypescript

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