Core Philosophy
Valchecker is built from immutable step plugins executed in a deterministic pipeline. Runtime behavior, TypeScript inference, editor discoverability, and tree-shaking are treated as one design problem.
Pipeline model
Input → initial step → validation → transformation → validation → Output
↓ ↓
issues issuesA reached step can:
- succeed and forward a value,
- succeed with a transformed value,
- fail with structured issues,
- recover from an earlier failure,
- delegate to another schema.
Names expose behavior
Built-in names deliberately identify a step's role:
| Role | Convention | Examples |
|---|---|---|
| Initial schema | noun or noun phrase | string(), number(), object(), looseBoolean() |
| Built-in validation | isXxx() | isFinite(), isInteger(), isLengthAtLeast() |
| Concrete transformation | toXxx() | toTrimmed(), toSplit(), toJSONValue() |
| Generic operation | direct verb | check(), transform(), fallback(), use() |
This convention is not cosmetic. After a schema narrows to a particular output type, editor autocomplete can group the meaningful next operations under is and to.
const schema = v.string()
.toTrimmed()
.isNotEmpty()
.isLengthAtMost(100)
.toLowercase()check() and transform() remain explicit generic escape hatches because names such as isValid() or toTransformed() would conceal their callback-defined behavior.
Initial schemas identify runtime domains
Primitive initial steps align with TypeScript primitive identities:
v.string() // typeof value === 'string'
v.number() // typeof value === 'number'
v.boolean() // typeof value === 'boolean'
v.bigint() // typeof value === 'bigint'The important consequence is that number() accepts NaN, Infinity, and -Infinity. They are JavaScript numbers and belong to TypeScript's number type.
Finite-number policy is a validation constraint, not hidden type identity:
v.number()
.isFinite()Each named validation checks only the condition it states:
v.number()
.isAtLeast(0) // Infinity succeeds
v.number()
.isFinite()
.isAtLeast(0) // Infinity failsThis keeps steps orthogonal and composition predictable.
Loose primitives normalize typed representations
Loose primitives accept a canonical primitive or its matching TypeScript template-literal string representation, then output the canonical primitive:
v.looseNumber() // number | `${number}` → number
v.looseBoolean() // boolean | `${boolean}` → boolean
v.looseBigint() // bigint | `${bigint}` → bigintThey do not mirror JavaScript constructors or truthiness:
v.looseBoolean()
.execute('false') // { value: false }
v.looseBoolean()
.execute(1) // failure
v.looseNumber()
.execute('') // failure, not 0Validation and transformation stay distinct
A validation preserves a successful value:
v.number()
.isFinite()
.isInteger()
.isAtLeast(0)A transformation changes the output value or representation:
v.string()
.toTrimmed()
.toSplit(',')
.toLength()The inferred output follows each transformation automatically.
Immutable schemas
Every chained method returns a new schema:
const rawName = v.string()
const trimmedName = rawName.toTrimmed()
const requiredName = trimmedName.isNotEmpty()No schema is mutated. Reusing an earlier pipeline is safe and deterministic.
Structured issues
Validation failures are returned as data:
type Result<T, Issue>
= | { value: T }
| { issues: [Issue, ...Issue[]] }
interface Issue {
code: string
category: 'validation' | 'operation' | 'internal'
message: string
path: PropertyKey[]
payload: unknown
context?: IssueContext[]
}
interface IssueContext {
type: string
[key: string]: unknown
}Codes identify the failing contract, while payloads preserve machine-readable context:
v.number()
.isAtLeast(10)
.execute(5)
// issue code: isAtLeast:expected_at_least
// payload: { target: 'number', value: 5, minimum: 10 }Structures clone child issues while applying their documented path mapping. Union and variant preserve the child data path and append branch provenance to context.
Sync and maybe-async execution
Execution mode is determined by reached work, not merely by schema declaration:
const schema = v.string()
.check(async value => value.length > 0)
schema.execute('value') // Promise<ExecutionResult<string>>
schema.execute(42) // synchronous type failure; callback not reachedUse .toAsync() when an API boundary requires every invocation to return a native promise.
Structural composition
Structural steps orchestrate nested pipelines:
const user = v.object({
name: v.string()
.toTrimmed()
.isNotEmpty(),
age: v.looseNumber()
.isFinite()
.isInteger(),
tags: [v.array(v.string())],
})object()validates declared own properties and omits unknown output properties.strictObject()rejects unknown enumerable own string and symbol keys.looseObject()preserves unknown own properties.array(),tuple(),set(),map(), andrecord()validate and transform nested values.union()returns the first successful branch's transformed output.variant()dispatches directly from an own discriminator property.intersection()composes compatible branch outputs.
A one-element tuple marks an object field optional.
Tree-shakable fluent API
The default v instance provides every built-in step:
import { v } from 'valchecker'A selective instance registers only imported plugins:
import {
createValchecker,
isFinite,
number,
} from 'valchecker'
const v = createValchecker({ steps: [number, isFinite] })Both modes expose the same state-aware fluent model. Selective registration gives bundlers explicit control over runtime step inclusion.
Design principles
- Type-aligned — primitive schemas match TypeScript identities.
- Explicit — runtime policy is expressed through named constraints.
- Composable — a step enforces only its own contract.
- Discoverable — method names and type state guide editor autocomplete.
- Immutable — schemas can be safely reused.
- Deterministic — failures are structured results, not validation exceptions.
- Extensible — custom plugins use the same core protocol.
- Tree-shakable — fluent DX does not require bundling every implementation.
Production guidance
Define reusable schemas outside hot paths, select only the step plugins needed by bundle-sensitive applications, and log structured issue codes and paths instead of parsing human-readable messages.
Next steps
- Valchecker 1.0 Contract — normative runtime semantics
- Custom Steps — plugin-author API
- API Reference — built-in steps
- Examples — applied patterns