Building Custom Validators
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
| Function | Purpose |
|---|---|
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)— successparseFail(errors)— multiple errorssingleError(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.