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"),
})
Enter fullscreen mode Exit fullscreen mode
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(),
}),
)
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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.
}
Enter fullscreen mode Exit fullscreen mode
const env = defineEnv({
THEME_PRIMARY: hexColor().default("#3b82f6"),
THEME_SECONDARY: hexColor().optional(),
})
Enter fullscreen mode Exit fullscreen mode
Error Helpers
Function Error Code WhenerrMissing(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
}
Enter fullscreen mode Exit fullscreen mode
Schema Composition in Monorepos
The recommended monorepo layout:
packages/
shared/ ← defineSchema with shared validators
api/ ← extendSchema + defineEnv
worker/ ← extendSchema + defineEnv (different subset)
Enter fullscreen mode Exit fullscreen mode
// 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),
})
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
})
Enter fullscreen mode Exit fullscreen mode
Summary
Pattern What WhydefineSchema()
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
Enter fullscreen mode Exit fullscreen mode
Previous: Testing and Debugging Your Env Config
답글 남기기