Core Philosophy
Valchecker is built around modular "steps" that execute in a deterministic pipeline. Each step validates, transforms, or short-circuits data while preserving TypeScript inference. This guide explains the mental model so you can design reliable validation flows and extend the library confidently.
Everything is a Step
- A step is a small plugin function that receives the current execution state and returns either a success result or validation issues.
- Steps are composed through helper factories like
string(),number(),array(), or authored manually usingimplStepPlugin()from@valchecker/internal. - Because steps are plain functions, you can reuse them across CLI tools, API handlers, background jobs, or any runtime surface.
// Using built-in steps
const schema = v.string()
.toTrimmed()
.min(3)
// Steps chain together to form a pipelineThe Pipeline Contract
Schema Creation: Chain steps together. Complex structures like
object,array,union, andintersectionorchestrate nested pipelines internally.Execution: Calling
schema.execute(value)returns a discriminated union:- Success:
{ value: T }whereTis the inferred output type - Failure:
{ issues: ExecutionIssue[] }with structured error information
- Success:
Issue Structure: Each issue includes:
code: Identifier like'string:expected_string'or'check:failed'message: Human-readable error descriptionpath: Array describing the location in nested data (e.g.,['user', 'email'])payload: Raw metadata about the failure
Async Detection: Pipelines automatically switch to async mode when any step returns a
Promise. Mix sync and async steps freely.
const pipeline = v.string()
.toTrimmed()
.check(value => value.length > 0, 'String cannot be empty')
.transform(value => value.toUpperCase())
const result = await pipeline.execute(' hello ')
// => { value: 'HELLO' }Message Resolution Priority
Error messages are resolved in the following order:
Per-step override: Passed directly to the step
tsv.number() .min(1, 'Quantity must be at least 1')Global handler: Defined when creating the valchecker instance
tsconst v = createValchecker({ steps: allSteps, message: ({ code, payload }) => translate(code, payload), })Built-in fallback: Default message from the step implementation
This allows you to centralize translations while still overriding specific cases.
Paths and Traceability
- Each issue carries a
patharray showing how to reach the failing value - Structural steps (
object,array) automatically append keys or indexes - Custom steps should append path segments when descending into nested data
const schema = v.object({
user: v.object({
email: v.string(),
}),
})
const result = schema.execute({ user: { email: 123 } })
// result.issues[0].path === ['user', 'email']This makes it trivial to highlight the exact field in forms or API responses.
Extending Valchecker
To create custom validation steps, use implStepPlugin() following the pattern from existing steps:
import type { DefineStepMethod, DefineStepMethodMeta, TStepPluginDef } from '@valchecker/internal'
import { implStepPlugin } from '@valchecker/internal'
// 1. Define metadata
type Meta = DefineStepMethodMeta<{
Name: 'positiveNumber'
ExpectedThis: DefineExpectedValchecker<{ output: number }>
SelfIssue: ExecutionIssue<'positiveNumber:not_positive', { value: number }>
}>
// 2. Define plugin interface
interface PluginDef extends TStepPluginDef {
positiveNumber: DefineStepMethod<
Meta,
this['This'] extends Meta['ExpectedThis']
? () => Next<{ output: number, issue: Meta['SelfIssue'] }, this['This']>
: never
>
}
// 3. Implement the step
export const positiveNumber = implStepPlugin<PluginDef>({
positiveNumber: ({ utils: { addSuccessStep, success, failure, resolveMessage } }) => {
addSuccessStep((value) => {
if (value > 0) {
return success(value)
}
return failure({
code: 'positiveNumber:not_positive',
payload: { value },
message: resolveMessage(
{ code: 'positiveNumber:not_positive', payload: { value } },
null,
'Value must be positive',
),
})
})
},
})See packages/internal/src/steps/ for complete examples of primitive, structural, and transformation steps.
Testing Requirements
- Every step
.tsfile must have a sibling.test.tswith 100% code coverage - Tests use Vitest and should cover success paths, failure paths, async variants, and edge cases
- Run the full verification sequence after changes:bash
pnpm -w lint pnpm -w typecheck pnpm -w test
Production Best Practices
- Selective imports: Use tree-shaking in production to exclude unused steps
- Schema reuse: Define schemas once and reuse them—avoid recreating inside hot paths
- Observability: Capture
issuesin monitoring tools—they contain structured codes for dashboards - Documentation: Document custom steps so consumers understand configuration and failure modes
Design Principles
- Deterministic: Same input always produces the same result
- Composable: Steps combine without special handling
- Type-safe: Full inference through transforms and checks
- Extensible: Add custom steps without modifying core
- Debuggable: Structured issues enable precise error reporting