CtroEnv
ctroenvType-Safe Environment Variables
Getting StartedQuick StartCore Concepts
defineEnv()string()number()boolean()semver()pick()ip(), ipv4(), ipv6()uuid(), guid()Chainable MethodsRefinementsError HandlingSchema CompositionSecurityCustom ValidatorswatchEnv()
CLI Overviewctroenv validatectroenv generatectroenv checkctroenv docsctroenv initCLI Configuration
Node AdapterVite AdapterNext.js Adapter
Migration from t3-envMigration from envalidMigration from dotenv

Schema Composition

Share and extend environment variable schemas across packages with defineSchema and extendSchema.

  1. Docs
  2. Core API

Schema Composition

In monorepos, multiple packages often need different subsets of environment variables while sharing common ones. Schema composition lets you define shared contracts once and extend them per-service.

defineSchema()

Defines a reusable schema block. At runtime it's an identity function — it returns the same object. At type level it preserves exact validator types for extension.

import { defineSchema, string, number } from "@ctroenv/core"

export const databaseSchema = defineSchema({
  DATABASE_URL: string().url().describe("PostgreSQL connection URL"),
  DATABASE_POOL_SIZE: number().default(10),
})

extendSchema()

Merges a base schema with additional validators. Extension keys override base keys when they conflict.

import { defineEnv, extendSchema } from "@ctroenv/core"
import { databaseSchema } from "./database-schema"

export const schema = extendSchema(databaseSchema, {
  PORT: number().port().default(3000),
  JWT_SECRET: string().secret(),
})

export const env = defineEnv(schema)

Key conflict behavior

When a key exists in both the base and extension, the extension wins:

const base = defineSchema({ PORT: number().default(3000) })
const extended = extendSchema(base, { PORT: number().default(4000) })

extended.PORT.metadata.defaultValue // 4000

In development mode (NODE_ENV === "development"), a warning is logged when conflicts occur.

Monorepo pattern

The recommended pattern for monorepos:

packages/
  shared/
    src/
      index.ts      ← defineSchema with shared validators
  api/
    src/
      env.ts        ← extendSchema + defineEnv
  worker/
    src/
      env.ts        ← extendSchema + defineEnv (different subset)

Shared schema package

// packages/shared/src/index.ts
import { defineSchema, pick, string } from "@ctroenv/core"

export const base = defineSchema({
  NODE_ENV: pick(["development", "staging", "production"] as const).default("development"),
  DATABASE_URL: string().url().secret().describe("PostgreSQL connection URL"),
  JWT_SECRET: string().min(32).secret().describe("JWT signing secret"),
})

Per-service extension

// packages/api/src/env.ts
import { defineEnv, extendSchema, number, string } from "@ctroenv/core"
import { base } from "@example/shared-config"

const schema = extendSchema(base, {
  PORT: number().port().default(3000),
  CORS_ORIGIN: string().url().describe("Allowed CORS origin"),
})
// packages/worker/src/env.ts
import { defineEnv, extendSchema, number, string } from "@ctroenv/core"
import { base } from "@example/shared-config"

const schema = extendSchema(base, {
  QUEUE_CONCURRENCY: number().int().min(1).default(5),
  WORKER_TIMEOUT: number().int().min(1000).default(30000),
})

Each service gets exactly the env vars it needs, sharing common validation logic through the base schema.

Client/Server Schema

Framework adapters (Next.js, Vite) split env vars into server-only and client-safe groups. The ClientServerSchema type defines this shape and InferredClientServerEnv merges both sides into one flat type:

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

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

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

Adapters own the visibility logic — core only defines the shape. See Next.js adapter and Vite adapter for framework-specific usage.

Chaining

Schemas can be chained to compose three or more layers:

const a = defineSchema({ A: string() })
const b = defineSchema({ B: string() })
const c = defineSchema({ C: string() })

const merged = extendSchema(extendSchema(a, b), c)

TypeScript inference

Composed schemas maintain full type inference through defineEnv():

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

env.PORT          // number
env.DATABASE_URL  // string
env.NODE_ENV      // "development" | "staging" | "production"

How is this guide?

Edit on GitHub

Last updated on Jun 30, 2026

PreviousError HandlingNextSecurity

On this page

defineSchema()extendSchema()Key conflict behaviorMonorepo patternShared schema packagePer-service extensionClient/Server SchemaChainingTypeScript inference