TypeScript Inference Tricks with CtroEnv
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