A composable, path-based validation library for TypeScript.
Mount validators and nested containers onto object paths, run them in groups, collect structured issues, and bridge to your favorite validator (zod, validator.js) or framework (Vue), all without decorators or schema DSLs.
Most validation libraries make you choose between two extremes: a schema DSL that bakes in every rule, or a hand-rolled function that tangles parsing, validation, and transformation. Validup picks a different point: mount any validator function onto any path of any input, compose containers, and let the runtime handle path expansion, error aggregation, and group filtering. You bring the validators (or wrap an existing library via an integration package); validup orchestrates them.
Table of Contents
This monorepo publishes one core library and four integration packages:
| Package | Version | Description |
|---|---|---|
validup | Core: Container, Validator, Issue, ValidupError | |
@validup/standard-schema | Bridge to any Standard Schema library (zod, valibot, arktype, …) | |
@validup/zod | Bridge to zod schemas (vendor-specific issue mapping) | |
@validup/validator-js | Pre-baked factories for validator.js (isEmail(), isLength(), …) + a generic wrap | |
@validup/vue | Vue 3 form composable: vuelidate-shaped state, nested forms, async + caching |
Install the core package:
npm install validup --saveOptionally add an integration:
npm install @validup/standard-schema --save # Standard Schema (zod 3.24+, valibot, arktype, …)
npm install @validup/zod --save # zod-specific (richer issue mapping)
npm install @validup/validator-js validator --save # validator.js string validators
npm install @validup/vue --save # Vue 3 formsimport{Container,ValidupError}from'validup';import{createValidator}from'@validup/zod';import{z}from'zod';constuser=newContainer<{name: string;email: string;age: number}>();user.mount('name',createValidator(z.string().min(2)));user.mount('email',createValidator(z.string().email()));user.mount('age',{optional: true},createValidator(z.number().int().positive()));try{constvalid=awaituser.run({name: 'Peter',email: 'peter@example.com',});// valid is { name, email }; `age` was optional and omitted}catch(error){if(errorinstanceofValidupError){console.error(error.issues);}}The same containers drive client-side forms. @validup/vue wraps any Container in a single composable, useValidup, and returns a vuelidate-shaped state object: per-field $model / $dirty / $errors, form-level $invalid / $pending, and $validate() for submit. Pair it with @validup/zod and the schemas you already run on the server validate every keystroke in the browser:
<script setup lang="ts">import { reactive } from'vue';import { Container } from'validup';import { useValidup } from'@validup/vue';import { createValidator } from'@validup/zod';import { z } from'zod';const signup =newContainer<{ email:string; password:string }>();signup.mount('email', createValidator(z.string().email()));signup.mount('password', createValidator(z.string().min(12)));const state =reactive({ email: '', password: '' });const v =useValidup(signup, state, { debounce: 200 });asyncfunction submit() {const result =awaitv.$validate();if (result.success) {awaitsave(result.data); }}</script>
<template>
<form@submit.prevent="submit">
<inputv-model="v.fields.email.$model" />
<pv-if="v.fields.email.$errors[0]">{{ v.fields.email.$errors[0].message }}</p>
<inputv-model="v.fields.password.$model"type="password" />
<pv-if="v.fields.password.$errors[0]">{{ v.fields.password.$errors[0].message }}</p>
<button:disabled="v.$invalid || v.$pending">Sign up</button>
</form>
</template>What you get beyond the basics:
- Async-aware: every scheduled run owns an
AbortController, so keystrokes cancel stale runs anddebouncecollapses bursts;$validate()deliberately runs without a signal so submit can't be aborted mid-flight. - Result caching: a per-form
ResultCachereplays unchanged fields, so editingnamenever re-fires the async uniqueness check onemail. - Nested forms: child components register with their ancestor form via
provide/inject; the parent reads them reactively through$getResultsForChildfor live aggregation. - Server errors: feed a
ValidupErrorfrom your API back withsetExternalIssues(); the issues land on the matching fields and clear as the user retypes. - Severity:
getSeverity(field)renders optional-mount failures as warnings instead of errors, so pristine forms stay friendly.
See the @validup/vue README, the Vue docs page, and the runnable playground/vite-vue demo (basic, groups, nested, async, server errors, and severity routes).
| Feature | What it gives you |
|---|---|
| 🧩 Composable | Mount validators and nested Containers on any path. Stay flat or nest as deep as you like. |
| 🌐 Universal | Pure JS: runs in Node.js, browsers, Deno, Bun, and edge runtimes. |
| 🎭 Integration-ready | First-class bridges to Standard Schema, zod, validator.js, and Vue. Trivial to add more. |
| 🛤️ Path-based | Mount via dotted paths (a.b.c), brackets (foo[0]), or globs (**.foo). |
| 🚦 Group-aware | Run different validations for create / update / custom groups from the same container. |
| ❓ Optional handling | Per-mount control over undefined / null / falsy semantics. |
| 📋 Structured errors | Discriminated Issue items and groups with code, path, message, expected, received. |
| 🛡️ Type-safe | Container<T> propagates the output shape; mount paths are checked against T. The opt-in defineSchema() builder accumulates T from the registered mounts so run()'s static return type matches what was registered. |
| ⚡ Cache-aware | Opt-in per-mount result cache ({ cache: new ResultCache() }); defineValidator({ sideEffect: true }) opts the rare cross-field / async validator out so per-keystroke runs reuse fresh results without re-firing slow checks. |
validup/
├── packages/
│ ├── validup/ # Core library
│ ├── standard-schema/ # @validup/standard-schema
│ ├── zod/ # @validup/zod
│ ├── validator-js/ # @validup/validator-js
│ └── vue/ # @validup/vue
├── docs/ # VitePress site (private)
├── playground/
│ └── vite-vue/ # Vite + Vue 3 demo app (private, multi-route)
├── nx.json # Nx caching for build / lint / test
└── release-please-config.json
The five packages are managed as an Nx workspace under npm workspaces. Integration packages depend on validup; the core depends on @ebec/core (which owns the issue model), pathtrace, smob and twinop. docs/ and every playground/* workspace are private, excluded from release-please and monoship.
The Issue model (Issue, IssueItem, IssueGroup, IssueCode, the factories, guards, tree walks, prefixIssuePath, formatIssue and interpolate) is defined in @ebec/core. It lives outside this repo so other libraries can share the shape without taking on validup's runtime; issue trees then compose across libraries because both sides reference the same types.
The integration packages import those symbols directly from @ebec/core and declare it as a dependency. As of validup v2.0.0, validup no longer re-exports the model: import { IssueCode } from 'validup' no longer resolves; import from @ebec/core instead.
The Vite + Vue playground lives at playground/vite-vue and exercises @validup/vue end-to-end (basic form, groups, nested forms, async + debounce, server errors, severity). Run it with npm run dev --workspace=@validup-playground/vite-vue.
# Install workspace dependencies
npm install
# Build every package (Nx topological build)
npm run build
# Run all test suites
npm run test# Typecheck the specs (packages that opt in)
npm run test:types
# Lint
npm run lint
npm run lint:fix- Node.js:
>=24.0.0(CI runs on 24) - Test runner: Vitest 4
- Bundler: tsdown; ESM-only output (
dist/index.mjs+dist/index.d.mts) - Lint: ESLint v10 flat config
- Releases: managed by release-please (one component per package); published via
tada5hi/monoship
Issues and pull requests are welcome. Please follow Conventional Commits; commit messages are linted via commitlint. CI runs install → build → lint → test on every PR.
For security vulnerabilities, please email contact@tada5hi.net rather than opening a public issue (see SECURITY.md).
Made with 💚
Published under Apache 2.0 License.