DEV Community

Cover image for Shipping Reusable Env Schemas
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

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"),
})
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 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
}
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 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
Enter fullscreen mode Exit fullscreen mode

Links: GitHub · Docs · npm

Previous: Testing and Debugging Your Env Config

Top comments (0)