valchecker
v0.0.33
Published
Runtime-first TypeScript validation with a tree-shakeable fluent API.
Maintainers
Readme
Valchecker
A modular TypeScript validation library with composable immutable steps, full transformed-output inference, structured issues, and a tree-shakable fluent API.
Installation
pnpm add valchecker
# or
npm install valcheckerQuick start
import { v } from 'valchecker'
const userSchema = v.object({
name: v.string().toTrimmed().isNotEmpty(),
age: v.number().isFinite().isInteger().isAtLeast(0),
tags: v.array(v.string()).isLengthAtMost(10),
})
const result = userSchema.execute({
name: ' Alice ',
age: 30,
tags: ['typescript'],
})
if (v.isSuccess(result)) {
console.log(result.value)
}
else {
console.error(result.issues)
}Selective imports
import {
createValchecker,
isAtLeast,
isFinite,
number,
} from 'valchecker'
const v = createValchecker({
steps: [number, isFinite, isAtLeast],
})
const schema = v.number().isFinite().isAtLeast(0)Use allSteps when a custom instance should include every built-in plugin.
Map and Set collections
const tags = v.set(v.string().toTrimmed().toLowercase())
.isNotEmpty()
.isSizeAtMost(5)
.isIncluding('required')
const scoreCount = v.map({
key: v.string().toTrimmed(),
value: v.number().isFinite(),
})
.isIncludingKey('primary')
.isIncludingValue(1)
.toSize()Both initial schemas preserve insertion order, return new transformed collections, and expose stable child paths. Duplicate transformed Set items or Map keys are validation failures rather than silent data loss.
Map and Set schemas expose size-aware emptiness checks, isSizeAtLeast(), isSizeAtMost(), isSizeExactly(), and toSize(). Set membership uses isIncluding(), while Map membership is explicit through isIncludingKey() and isIncludingValue().
Use toArray() for Set items and toKeys(), toValues(), or toEntries() for explicit Map representations. Each transform returns a new insertion-ordered array and emits no new issue.
Set schemas expose toMapped() and toFiltered(). Map schemas expose explicit toMappedKeys() and toMappedValues() transforms. These callbacks traverse a step-start snapshot synchronously; mapped Set items and Map keys must remain unique under SameValueZero.
Records and tuples
const ratings = v.record({ key: v.string(), value: v.number() }) // { [k: string]: number }
const flags = v.record({ key: v.union(['read', 'write']), value: v.boolean() }) // { read: boolean, write: boolean }
const point = v.tuple([v.string(), v.number(), '...', v.array(v.number())]) // [string, number, ...number[]]record() validates every own enumerable entry as a plain object Record<K, V>. Finite literal-union keys produce an all-required, exhaustive object; wide keys produce an open index signature and must transform uniquely. tuple() validates a fixed-shape array positionally, with a single optional '...' rest region whose array output is spread. The object optional-field [schema] shorthand never collides with tuple().
Direct variant dispatch
const event = v.variant({
discriminator: 'type',
variants: {
click: v.object({ type: v.literal('click'), x: v.number(), y: v.number() }),
keypress: v.object({ type: v.literal('keypress'), key: v.string() }),
},
})variant() reads an own discriminator, uses JavaScript property-key semantics, and executes only the selected branch. Use union() when branch selection itself requires ordered validation.
Step naming
Valchecker separates API roles through naming:
- initial steps are nouns:
string(),number(),object(),looseBigint(), - built-in validations use
isXxx():isInteger(),isStartingWith(),isLengthAtLeast(), - concrete transformations use
toXxx():toTrimmed(),toNumber(),toJSONValue(), - generic escape hatches remain
check()andtransform().
This makes the valid next operations discoverable through editor autocomplete.
Type-aligned primitives
Primitive initial steps match TypeScript primitive types. number() accepts every JavaScript number, including NaN, Infinity, and -Infinity.
v.number().execute(Number.NaN) // { value: NaN }
v.number().execute(Infinity) // { value: Infinity }Use explicit validation when the application requires a narrower runtime domain:
v.number().isFinite()
v.number().isInteger()
v.number().isFinite().isAtLeast(0).isAtMost(100)Loose primitives
Loose primitives accept the primitive itself or its matching TypeScript template-literal representation, then normalize the output:
v.looseNumber().execute('1e3') // { value: 1000 }
v.looseBoolean().execute('false') // { value: false }
v.looseBigint().execute('-0x10') // { value: -16n }Their input contracts correspond to:
type LooseNumberInput = number | `${number}`
type LooseBooleanInput = boolean | `${boolean}`
type LooseBigintInput = bigint | `${bigint}`They do not perform unrestricted JavaScript coercion. For example, looseBoolean() rejects 'TRUE', 1, and arbitrary truthy values.
Template literals
templateLiteral(parts) validates a string against an assembled TypeScript template-literal type and infers that exact output type, with cross-product union expansion. Matching mirrors the TypeScript checker's placeholder split rule, not a regex.
v.templateLiteral(['ID-', v.number()]).execute('ID-42') // { value: 'ID-42' }, output `ID-${number}`
v.templateLiteral([v.number(), v.union(['px', 'em', 'rem'])]) // output `${number}px` | `${number}em` | `${number}rem`Built-in validations
v.string().isEmpty()
v.string().isNotEmpty()
v.string().isStartingWith('prefix')
v.string().isEndingWith('.json')
v.string().isLengthAtLeast(3).isLengthAtMost(20)
v.array(v.string()).isLengthAtLeast(1)
v.number().isFinite()
v.number().isNaN()
v.number().isInteger()
v.number().isAtLeast(0).isAtMost(100)isAtLeast() and isAtMost() apply to numbers and bigints. Length and size constraints are intentionally separate and explicit.
String formats
Dedicated isXxx() validators cover common string formats, each with its own semantic issue code (isEmail:expected_email, isUrl:expected_url, …):
v.string().isEmail()
v.string().isUrl() // http/https; override with { protocols: [...] }
v.string().isUuid()
v.string().isIp({ version: 6 })
v.string().isIsoDateTime()
v.string().isJwt()
v.string().isEmoji()Also available: isHex(), isMac(), isHostname(), isBase64(), isBase64Url(), isCuid2(), isUlid(), isNanoid(), isIsoDate(), and isIsoTime(). Each is value-preserving and enforces only its named format.
File and Blob
file() and blob() validate File and Blob values with feature-detected globals, so they fail gracefully in environments where the constructors are absent. MIME validation uses isMimeType(), and size validation reuses the collection size steps because both expose a numeric size.
v.file()
.isMimeType(['image/*', 'application/pdf'])
.isSizeAtMost(5 * 1024 * 1024)
v.blob().isMimeType('application/json')isMimeType() matches a single type or a list, supports image/*-style wildcards, and compares case-insensitively.
Primitive conversions
Native coercion steps delegate directly to JavaScript and are exposed after any output that is not already the target primitive type:
v.string().toNumber() // Number(value)
v.unknown().toBoolean() // Boolean(value)
v.object({ value: v.number() }).toBigint() // BigInt(value), with native exceptions as issuesThey do not hide extra safety policy:
v.string().toNumber().execute('invalid') // { value: NaN }
v.string().toBoolean().execute('false') // { value: true }
v.bigint().toNumber().execute(9007199254740993n)
// { value: 9007199254740992 }Native exceptions from Number() and BigInt() become structured issues. Explicit policy conversions are separate:
v.bigint().toSafeNumber()
v.string().toMappedBoolean({
trueValues: ['Y', 'yes'],
falseValues: ['N', 'no'],
})
v.number().toMappedBoolean({
trueValues: [1],
falseValues: [0],
})Identity conversions such as number().toNumber(), boolean().toBoolean(), and bigint().toBigint() are unavailable through the state-aware API.
Other transformations
v.string().toTrimmed()
v.string().toLowercase()
v.string().toSplit(',')
v.string().toJSONValue()
v.unknown().toJSONString()
v.array(v.number()).toSorted()
v.set(v.string()).toSize()Use transform() for arbitrary output transformations:
v.string().transform(value => ({ value }))Custom validation
Use check() as the generic validation escape hatch:
const positive = v.number().check(value => value > 0, { message: 'Must be positive' }
)Callbacks may be synchronous or asynchronous according to the individual step contract. Built-in callback throws and rejections become their documented operation issues.
Results
Successful execution returns the final transformed value:
type Success<T> = { value: T }Failure returns structured issues:
type Failure<Issue> = { 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
}Use v.isSuccess(result) and v.isFailure(result) as result type guards.
Documentation
See the complete documentation and semantic contract at the Valchecker documentation site.
License
MIT
