Schema Composition for Library Authors
Schema Composition for Library Authors
If your library needs environment variables, publishing them as a CtroEnv schema lets consumers extend, override, and validate them alongside their own.
Why Library Authors Should Care
A database library needs DATABASE_URL. An auth library needs JWT_SECRET. A queue library needs REDIS_URL. Each library currently documents these requirements — and each consumer manually adds them to their env setup.
With CtroEnv, libraries can ship their schemas as exports. Consumers compose them in one line.
Publishing a Schema
// @myapp/database/src/env.ts
import { defineSchema, string, number } 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"),
})
The defineSchema() call is an identity function at runtime but preserves exact types at the type level. Consumers import and extend:
// consumer/src/env.ts
import { defineEnv, extendSchema, string, number } from "@ctroenv/core"
import { databaseSchema } from "@myapp/database"
const env = defineEnv(
extendSchema(databaseSchema, {
PORT: number().port().default(3000),
JWT_SECRET: string().min(32).secret(),
}),
)
Handling Version Conflicts
If two libraries depend on different versions of the same schema package, CtroEnv handles it through npm's standard dependency resolution. Each library gets its own copy of the schema definition — the consumer's schema composes them:
import { extendSchema } from "@ctroenv/core"
import { databaseSchema } from "@myapp/database" // v1
import { authSchema } from "@myapp/auth" // v2
// Both schemas are independent — no conflict
const schema = extendSchema(databaseSchema, authSchema)
Dev-Mode Conflict Warnings
extendSchema() warns on key conflicts in development mode:
const base = defineSchema({ PORT: number().default(3000) })
const extended = extendSchema(base, { PORT: number().default(4000) })
// ⚠ [ctroenv] Key "PORT" defined in both base and extension. Extension wins.
This catches accidental overrides during development.
Publishing the Schema
Structure your library's export:
// @myapp/database/src/index.ts
export { databaseSchema } from "./env"
export { createConnection } from "./connection"
Consumers import just the schema:
import { databaseSchema } from "@myapp/database"
Tree shaking ensures only the schema (not the full library) is included if that's all they use.
Schema as Documentation
The schema serves as living documentation:
export const databaseSchema = defineSchema({
DATABASE_URL: string().url()
.describe("PostgreSQL connection string"),
DB_POOL_SIZE: number().int().min(1).max(100)
.default(10)
.describe("Maximum connections in the pool"),
DB_SSL: pick(["require", "prefer", "disable"] as const)
.default("prefer")
.describe("SSL mode for database connection"),
})
Run ctroenv docs to generate ENVIRONMENT.md from the schema — every var with its type, default, and description, always in sync.
Testing the Schema
Test your schema independently:
import { defineEnv, objectSource } from "@ctroenv/core"
import { databaseSchema } from "./env"
it("accepts valid config", () => {
const env = defineEnv(databaseSchema, {
source: objectSource({
DATABASE_URL: "postgresql://localhost:5432/test",
}),
})
expect(env.DATABASE_URL).toBe("postgresql://localhost:5432/test")
expect(env.DB_POOL_SIZE).toBe(10) // default
})
it("rejects missing required vars", () => {
expect(() => defineEnv(databaseSchema, {
source: objectSource({}),
})).toThrow()
})
Pattern Summary
| Step | What | Why |
|---|---|---|
| 1 | defineSchema() in library | Type-safe, extendable schema definition |
| 2 | Export schema from library | Consumable by any CtroEnv project |
| 3 | extendSchema() in consumer | Compose library schemas with app-specific vars |
| 4 | ctroenv docs from schema | Auto-generated environment documentation |
| 5 | defineEnv() at startup | Validate everything at once |