Your library needs DATABASE_URL. Another needs JWT_SECRET. Instead of documenting requirements in a README that goes stale, ship them as CtroEnv schemas.
Publishing a Schema from Your Library
defineSchema() is an identity function at runtime — it returns the same object you pass in. At the type level, it preserves exact validator types for composition.
// @myapp/database/src/env.ts
import { defineSchema, string, number, pick } from "@ctroenv/core"
export const databaseSchema = defineSchema({
DATABASE_URL: string().url().describe("PostgreSQL connection string"),
DB_POOL_SIZE: number().int().min(1).max(100).default(10),
DB_SSL: pick(["require", "prefer", "disable"] as const).default("prefer"),
})
Consumers extend it:
// consumer/src/env.ts
import { defineEnv, extendSchema } from "@ctroenv/core"
import { databaseSchema } from "@myapp/database"
const env = defineEnv(
extendSchema(databaseSchema, {
PORT: number().port().default(3000),
JWT_SECRET: string().min(32).secret(),
}),
)
The consumer's env has all four database vars plus their own. Types merge automatically.
Conflict Handling
If the base and extension define the same key, extension wins:
const base = defineSchema({ PORT: number().default(3000) })
const extended = extendSchema(base, { PORT: number().default(4000) })
// PORT resolves to 4000
In development mode, a warning is logged when conflicts occur.
Building Custom Validators
For domain-specific formats not covered by built-in validators, use createValidator():
import {
createValidator, applyChain,
parseOk, singleError,
errType, errInvalid,
} from "@ctroenv/core"
function hexColor() {
const base = createValidator<string>(
(input, ctx) => {
if (typeof input !== "string") {
return singleError(errType(ctx.key, typeof input, "hex color"))
}
if (!/^#[0-9a-fA-F]{3,6}$/.test(input)) {
return singleError(errInvalid(ctx.key, input, "not a valid hex color"))
}
return parseOk(input)
},
{ typeLabel: "hexcolor" },
)
return applyChain(base) // adds .optional(), .default(), .secret(), etc.
}
const env = defineEnv({
THEME_PRIMARY: hexColor().default("#3b82f6"),
THEME_SECONDARY: hexColor().optional(),
})
Error Helpers
| Function | Error Code | When |
|---|---|---|
errMissing(key) |
missing_required |
Variable not found |
errType(key, received, expected) |
type_mismatch |
Wrong JavaScript type |
errInvalid(key, value, msg) |
invalid_value |
Failed validation |
errWrap(key, value, msg, code) |
Custom | Generic wrapper |
Adding Refinement Methods
Custom validators can expose type-specific methods like .v4() on ip():
function ip() {
const base = createValidator<string>(/* ... */)
const chainable = applyChain(base) as typeof applyChain<string>
& { v4(): typeof chainable }
chainable.v4 = () => {
const original = chainable
const wrapped = createValidator<string>(
(input, ctx) => {
const r = original.parse(input, ctx)
if (!r.success) return r
if (r.value.includes(":"))
return singleError(errInvalid(ctx.key, r.value, "not IPv4"))
return r
},
original.metadata,
)
return applyChain(wrapped) as typeof chainable
}
return chainable
}
Schema Composition in Monorepos
The recommended monorepo layout:
packages/
shared/ ← defineSchema with shared validators
api/ ← extendSchema + defineEnv
worker/ ← extendSchema + defineEnv (different subset)
// packages/shared/src/index.ts
export const base = defineSchema({
NODE_ENV: pick(["dev", "staging", "prod"] as const).default("dev"),
LOG_LEVEL: pick(["debug", "info", "warn", "error"] as const).default("info"),
})
// packages/api/src/env.ts
const schema = extendSchema(base, {
PORT: number().port().default(3000),
DATABASE_URL: string().url(),
})
// packages/worker/src/env.ts
const schema = extendSchema(base, {
QUEUE_CONCURRENCY: number().int().min(1).default(5),
})
Each package imports only what it needs. Adding a shared var in the base schema propagates to all packages on rebuild.
Auto-Generated Docs
Run ctroenv docs to generate ENVIRONMENT.md from your schema:
npx ctroenv docs --output ENVIRONMENT.md
Every variable with its type, default, and .describe() text — always in sync with the schema.
Testing Schemas
Export your schema for testing:
// schema.test.ts
import { defineEnv, objectSource } from "@ctroenv/core"
import { databaseSchema } from "./schema"
it("validates with defaults", () => {
const env = defineEnv(databaseSchema, {
source: objectSource({
DATABASE_URL: "postgresql://localhost:5432/test",
}),
})
expect(env.DB_POOL_SIZE).toBe(10) // default
})
Summary
| Pattern | What | Why |
|---|---|---|
defineSchema() |
Publishable schema block | Reusable across packages |
extendSchema() |
Compose schemas | Don't repeat definitions |
createValidator() |
Custom validator | Domain-specific formats |
ctroenv docs |
Auto-generated docs | Always in sync |
objectSource() |
Test with mock env | No global pollution |
npm install @ctroenv/core
Previous: Testing and Debugging Your Env Config
Top comments (0)