Shipping Reusable Env Schemas

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

Links: GitHub · Docs · npm

Previous: Testing and Debugging Your Env Config

Leave a Reply