TypeScript Inference Tricks with CtroEnv
How CtroEnv infers exact types from validators without manual type definitions.
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