Building Custom Validators

2026-06-20·Ctrotech·
guideadvanced

Building Custom Validators

CtroEnv's built-in validators cover common cases. When you need something domain-specific, createValidator() and applyChain() let you build your own.

The Building Blocks

FunctionPurpose
createValidator(parseFn, meta)Create a base validator with a parse function
applyChain(validator)Add .optional(), .default(), .describe(), .secret(), .validate()
parseOk(value)Return a successful parse result
parseFail(errors)Return a failed parse result
singleError(error)Convenience for a single error result
errMissing(key)Error: missing required variable
errType(key, received, expected)Error: wrong type
errInvalid(key, value, msg)Error: value failed validation
errWrap(key, value, msg, code)Generic error wrapper

Simple Example: SemVer Validator

A validator that accepts only valid semantic version strings:

import {
  createValidator, applyChain,
  parseOk, singleError,
  errType, errInvalid,
} from "@ctroenv/core"

function semver() {
  const base = createValidator<string>(
    (input, ctx) => {
      if (typeof input !== "string") {
        return singleError(
          errType(ctx.key, typeof input, "semver")
        )
      }
      if (!/^\d+\.\d+\.\d+$/.test(input)) {
        return singleError(
          errInvalid(ctx.key, input, "not valid semver")
        )
      }
      return parseOk(input)
    },
    { typeLabel: "semver" },
  )
  return applyChain(base)
}

Usage:

const env = defineEnv({
  APP_VERSION: semver(),
  NODE_VERSION: semver().optional(),
})

With Custom Refinements

Add type-specific methods like .v4() on the ip() validator:

function ip() {
  const base = createValidator<string>(
    (input, ctx) => {
      if (typeof input !== "string") {
        return singleError(errType(ctx.key, typeof input, "IP address"))
      }
      if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(input) &&
          !/^[0-9a-f:]+$/i.test(input)) {
        return singleError(errInvalid(ctx.key, input, "not a valid IP"))
      }
      return parseOk(input)
    },
    { typeLabel: "ip" },
  )

  const chainable = applyChain(base) as ReturnType<typeof applyChain<string>>
    & { v4(): ReturnType<typeof applyChain<string>> }
    & { v6(): ReturnType<typeof applyChain<string>> }

  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 an IPv4 address"))
        }
        return r
      },
      original.metadata,
    )
    return applyChain(wrapped) as typeof chainable
  }

  chainable.v6 = () => {
    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 an IPv6 address"))
        }
        return r
      },
      original.metadata,
    )
    return applyChain(wrapped) as typeof chainable
  }

  return chainable
}

Returning Errors

The parse function returns one of:

  • parseOk(value) — success
  • parseFail(errors) — multiple errors
  • singleError(error) — a single error

Each error is a ValidationError with key, message, code, value, suggestion, and originalValue fields.

Testing Custom Validators

Use objectSource() to test without mocking process.env:

import { defineEnv, objectSource } from "@ctroenv/core"

it("accepts valid semver", () => {
  const env = defineEnv(
    { VERSION: semver() },
    { source: objectSource({ VERSION: "1.2.3" }) },
  )
  expect(env.VERSION).toBe("1.2.3")
})

it("rejects invalid semver", () => {
  expect(() => defineEnv(
    { VERSION: semver() },
    { source: objectSource({ VERSION: "abc" }) },
  )).toThrow()
})

When to Use Custom Validators

  • Domain formats not covered by built-ins (hex colors, currency codes, locale tags)
  • Company-specific patterns (internal service names, project IDs)
  • Cross-field validation wrapped in a reusable unit

If your validator gets complex, extract the parse logic into a separate pure function and test it independently from the validator wrapper.