Schema Composition for Library Authors

2026-06-24·Ctrotech·
guideadvanced

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

StepWhatWhy
1defineSchema() in libraryType-safe, extendable schema definition
2Export schema from libraryConsumable by any CtroEnv project
3extendSchema() in consumerCompose library schemas with app-specific vars
4ctroenv docs from schemaAuto-generated environment documentation
5defineEnv() at startupValidate everything at once