@aleksei-chernyi/pathmodel
| Install | |
|---|---|
npm install @aleksei-chernyi/pathmodel |
|
| Latest Version: | 0.2.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 11, 2026 |
| Links: | GitHub · npm |
pathmodel
Class-based data models addressed by dot paths, in TypeScript. You describe the shape of your
data as a class tree with decorators. Each field is bound to a dot path in the resulting value.
Fields that share a path stay in sync, and validation errors are keyed by path. FormBase
also builds a schema (a tree of layout and field nodes) from the classes, and the bundled
Vue 3 components render that schema as a form.
Contents
src/schema.ts: the@Schemadecorator, plusFieldNode/LayoutNodesrc/form.ts:FormBase,@FormOptions,@Nested,@NestedArraysrc/form-switch.ts:FormSwitch/FormCasefor forms with switchable cases or stepssrc/validation.ts: the validation adapter for Standard Schema librariessrc/component-kind.ts:COMPONENT_KINDand helpers that tell layout components from field componentssrc/template/:SchemaRenderer.vueandSchemaNodeRenderer.vue, which render the schemasrc/layout/: layout components:Row,Col,Wrapper,Switch,Stepper,Case,Empty
src/index.ts exports everything except the .vue files, which you import by path.
Installation
npm install @aleksei-chernyi/pathmodel reflect-metadata vue
Requirements
- Node.js >= 18
- A Vue 3 host app whose bundler compiles
.vueSFCs with<script setup lang="ts">and<style lang="scss">(for example Vite with@vitejs/plugin-vueandsass) reflect-metadata, imported once at startup- TypeScript with decorators enabled:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
import "reflect-metadata"
vue(^3.3) is a peer dependency. The host app provides it.
Class fields must use TypeScript's legacy semantics (useDefineForClassFields: false).
Otherwise a field declaration with no initializer, such as address!: AddressForm, resets the
nested form that FormBase created to undefined. With tsc, set the option in
tsconfig.json; a target of ES2021 or lower turns it off by default. Vite 8 compiles
TypeScript with Oxc, which needs this config:
// vite.config.ts
export default defineConfig({
oxc: {
assumptions: { setPublicClassFields: true },
typescript: { removeClassFieldsWithoutInitializer: true },
},
})
Concepts
Components
Every node has a Vue component. A component is a layout if it declares
defineOptions({ __kind: COMPONENT_KIND.LAYOUT }). Any other component is a field.
- Field components get the props
node(aFieldNode),root(the root form),error,fieldErrorandhasError. They read values withnode.getValue()and write them withnode.setValue(value). They can read resolved props withnode.getProps(). - Layout components get
node(aLayoutNode) androot, and render their children through the default slot. A layout component can definegetChildComponent()indefineOptions. When it does, each item of a@NestedArrayis wrapped in aLayoutNodethat uses that component. The slot then exposes the selected item asnested.SwitchandStepperwork this way, withCaseas the child component.
Decorators
| Decorator | Target | Purpose |
|---|---|---|
@Schema(options) |
property | Declares a field ({ component, dotPath, props, options? }) or a layout container ({ component, label?, props?, options? }). |
@Nested({ type }) |
property | The property is a nested form of class type. It is instantiated automatically. |
@NestedArray({ items }) |
property | The property is an array of nested forms, one instance per class in items, in order. |
@FormOptions(options) |
class | Sets the FormOptionsInterface passed to a nested class when it is created. |
A nested property needs both @Schema (with a layout component) and @Nested / @NestedArray.
The @Schema part decides how the property renders.
FormOptionsInterface:
title: shown as the tab or step label for array itemsnamespace?: dot path prefix used to count a step's validation errorskeepInValue?: defaults totrue. Iffalse, the form's fields are left out ofgetFormValue(). For aFormCase, they are left out unless the case is active.
Field options (FieldOptions):
default: the initial value (otherwisenull)required: an empty value is an error (see Validation)requiredMessage: error text. The default is"The field is required".rules: a Standard Schema (Zod, Valibot, ArkType, ...) that checks the field's value
A function value in a field's props is called with the owning form instance when
node.getProps() runs. Two prop keys have special meaning for the renderer:
validationErrorPaths: string[]: sub-paths under the field'sdotPathwhose errors also belong to this field. Useful for composite fields.inlineError: true: the field renders its own error, so the renderer skips the default.field-errorblock.
Dot paths
Each field's dotPath is its key in the form value. Several fields, even in different nested
forms, can share one dotPath. They then stay in sync: setValueByDotPath writes every
instance mapped to that path. The map is filled during buildSchema(), so call buildSchema()
before you read or write values.
Usage
import "reflect-metadata"
import { FormBase, FormOptions, Nested, Schema } from "@aleksei-chernyi/pathmodel"
import Row from "@aleksei-chernyi/pathmodel/src/layout/Row.vue"
import TextInput from "./TextInput.vue" // your field component
@FormOptions({ title: "Address" })
class AddressForm extends FormBase {
@Schema({ component: TextInput, dotPath: "address.city", props: { label: "City" } })
city!: string
}
class UserForm extends FormBase {
@Schema({
component: TextInput,
dotPath: "firstName",
props: { label: "First name" },
options: { required: true },
})
firstName!: string
@Schema({ component: Row })
@Nested({ type: AddressForm })
address!: AddressForm
}
const form = new UserForm({ title: "User" })
const nodes = form.buildSchema()
form.setFormValue({ firstName: "Ada", address: { city: "London" } })
form.getFormValue() // { firstName: "Ada", address: { city: "London" } }
Rendering:
<script setup lang="ts">
import { reactive } from "vue"
import SchemaRenderer from "@aleksei-chernyi/pathmodel/src/template/SchemaRenderer.vue"
// `reactive()` returns an unwrapped type; the cast keeps the class type for `:root`.
const form = reactive(new UserForm({ title: "User" })) as UserForm
const nodes = form.buildSchema()
</script>
<template>
<SchemaRenderer :nodes="nodes" :root="form" />
</template>
Switch and stepper forms
Extend FormSwitch for the container and FormCase for each case. Put the cases on a
@NestedArray property named cases, and render it with Switch (tabs) or Stepper
(numbered steps):
import { FormCase, FormSwitch } from "@aleksei-chernyi/pathmodel"
import Stepper from "@aleksei-chernyi/pathmodel/src/layout/Stepper.vue"
@FormOptions({ title: "Contacts", namespace: "contacts" })
class ContactsStep extends FormCase {
protected _case = "contacts"
// ...fields
}
class Wizard extends FormSwitch {
@Schema({ component: Stepper, options: { showHeader: true } })
@NestedArray({ items: [ContactsStep, PaymentStep] })
cases!: FormCase[]
}
toggleActiveCase(value)activates the case whose_caseequalsvalue.setActiveCaseIndex(i)activates a case by position.Stepperlocks steps aftergetMaxReachedCaseIndex(). UseunlockCaseIndex(i)andresetCaseProgress()to control progress. A step can only move forward ifvalidate()passes for the current step. Each step shows a badge with its error count, matched by field dot paths,validationErrorPathsand the casenamespace. The active step scrolls into view.- Layout options for both components:
showHeader(defaulttrue) andhideWhenNoActive.
Stepper styles use host CSS variables: --var-main-color, --var-button-bg-100,
--var-button-bg-400, --var-button-active-bg, --var-text-color-disabled, --var-danger
and --var-border-radius-big.
Other layouts
Row/Col: adivwith the class.form__row/.form__colWrapper: adivwith the classoptions.className(defaultform-wrapper), plus an optionaloptions.titleheaderEmpty: renders nothing. A field that uses it is skipped by the renderer.
Validation
pathmodel has no validation rules of its own. Rules come from any library that implements Standard Schema, such as Zod, Valibot or ArkType. Install the one you prefer in your app; pathmodel only depends on the spec's types.
Field rules
import { z } from "zod"
class ContactsForm extends FormBase {
@Schema({
component: TextInput,
dotPath: "contacts.email",
props: { label: "Email" },
options: { required: true, rules: z.email("Некорректный email") },
})
email!: string | null
@Schema({
component: AddressInput,
dotPath: "contacts.address",
props: { validationErrorPaths: ["city", "street"] },
// Issue paths are appended to the field's dot path: "contacts.address.city"
options: { rules: z.object({ city: z.string().min(1), street: z.string().min(1) }) },
})
address!: { city: string; street: string } | null
}
For each field:
- An empty value (
null,undefined, a blank string,[]) is an error if the field isrequired, and valid otherwise.rulesare not run on empty values, so optional fields don't need.nullable()or.optional(). - A non-empty value is checked against
rules.
Value schema
For rules that involve several fields, set a schema for the whole form value. It is checked
against getFormValue(), and each issue path is the dot path of the error:
form.setValueSchema(
z
.object({ account: z.object({ password: z.string(), confirm: z.string() }) })
.refine((v) => v.account.password === v.account.confirm, {
message: "Пароли не совпадают",
path: ["account", "confirm"],
})
)
Running validation
const ok = await form.validate() // whole form; replaces all errors
const stepOk = await form.validate(step.children) // only these nodes
With nodes, only errors at or under those nodes' dot paths are replaced. Other errors are
kept, for example server errors on other steps. Value schema issues outside those paths are
ignored. Stepper uses this to validate the current step.
You can also call validateWithSchema(schema, value, basePath?) directly to validate any
value and get errors keyed by dot path.
Error store
All errors live on the root form as Record<dotPath, string[]>.
| Method | Description |
|---|---|
validate(nodes?) |
Runs field rules and the value schema, stores the errors, resolves to true if there are none. |
setValueSchema(schema) |
Sets or clears (null) the schema for the whole form value. |
setValidationErrors(errors) |
Sets errors, for example from a server response. Call it on the root form. |
getValidationError(path) / getValidationErrors() |
Returns the first error for a path, or all errors. |
getValidationErrorByPaths(paths) |
Returns the first error across several paths. * matches one path segment. |
getValidationErrorsCount(ns) / getValidationErrorsCountByPaths(paths) |
Counts errors under a namespace prefix, or across paths (wildcards allowed). |
clearValidationError(path) / clearValidationErrors() |
Clears one path or all errors. Setting a value by dot path also clears that path's errors. |
Development
npm install
npm test # vitest
npm run lint # eslint
npm run format # prettier
npm run build # tsc -> dist/ (CommonJS + .d.ts)
Known gap in the current tooling:
tscdoes not compile.vuefiles, so they are not indist/. Consumers use them fromsrc/.
License
Related Packages
Headless, schema-driven form framework core for Vue 3 — elements, validation, co...
A field's address exists before the component that draws it: typed paths the com...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.0 | ^3.3.0 | MIT | |
| 0.1.1 | ^3.3.0 | MIT | |
| 0.1.0 | ^3.3.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |