What is Piying-View
Piying-View is an open-source, strongly typed TypeScript form library for the frontend.
You write one schema with Valibot (what the data looks like, what validation rules it has), and Piying-View turns it into a complete working form:
- All controls rendered automatically
- Two-way data binding handled for you
- Validation and error display handled for you
- Automatically reacts to data changes: hide / disable / couple fields
You can use it with Angular, Vue 3, Vue 2, React, Svelte, Solid. The same schema and business logic moves as-is — switching frameworks only means rewriting the registered components.
Capabilities at a glance
| Capability | How |
|---|---|
| Define the structure | Valibot schema: object / array / record / tuple / union / intersect / map / set
|
| Choose the control |
setComponent('input'), or set global defaults with fieldGlobalConfig
|
| Pass props to a component | inputs |
| Handle component events | outputs |
| Two-way binding |
models (fully supported in Angular) |
| Native DOM events | events |
HTML attributes / ARIA / data-*
|
attributes |
| Arbitrary custom metadata | props |
| Styling |
class.top / class.bottom / class.component
|
| Outer wrappers (label, error message, grid) | wrappers |
| Conditional hide / disable |
hideWhen / disableWhen
|
| Value coupling |
valueChange / outputChange
|
| Validation |
formConfig.validators / asyncValidators, or write Valibot validation directly |
| Value transformation |
formConfig.transformer (toView / toModel), pipe (RxJS), v.transform
|
| Layout adjustment |
layout({ priority }) for ordering, layout({ keyPath }) to move a field |
| Service injection |
providers (Angular uses inject(), other frameworks use static-injector) |
| Lifecycle | hooks |
| Treat a nested object as one control |
asControl() / asVirtualGroup()
|
| JSON Schema |
jsonSchemaToValibot() converts it automatically |
Two ways to use it
They can be nested freely:
-
Automatic mode:
<piying-view [schema]="...">— the library renders the whole component tree for you. -
Manual mode: call
convertToField()yourself to get afield, then bind it with a directive wherever you want to put it.
End to end strong typing and code completion
- Latest feature update
Without type hints
Actions have always worked, but they were loose about "the key you write" and "the field in your callback":
import * as v from 'valibot';
import { actions, setComponent } from '@piying/view-angular-core';
const schema = v.pipe(
v.string(),
setComponent('input'),
actions.inputs.set({ placeholder: 'Enter a name', maxLength: 50 }),
actions.inputs.patchAsync({
content: (field) => {
field.get(['address', 'city']); // Does this path even exist? You can't tell from the types
field.form.control.value; // any
field.form.root.value; // any
return '';
},
}),
);
-
inputsis typed asRecord<string, any>: keys are free-form, value types are not checked - The
fieldin callbacks is a generic shape:get()cannot resolve an accurate type,.valueisany - A typo like
palceholder, or using anumberwhere astringbelongs, only shows up at runtime
With type hints
Types are not an interface bolted on afterwards — they are inferred all the way down from the schema:
- Autocompleted paths: your editor completes paths from the schema structure and flags wrong ones
-
Typed callbacks:
fieldis the exact field type for that path, notany -
Typed values:
field.form.control.valueis the output type inferred from the schema; same forparent/root
typedFieldPipe — attach metadata to a schema by path
Adds actions (metadata) to an already defined schema and returns a new schema. It is written by path, and paths are derived from the schema structure:
import * as v from 'valibot';
import { typedFieldPipe } from '@piying/view-core';
const schema = v.object({
name: v.string(),
age: v.number(),
price: v.number(),
address: v.object({ city: v.string(), zip: v.string() }),
tags: v.array(v.object({ label: v.string(), count: v.number() })),
});
const merged = typedFieldPipe(schema, (d) => [
// Paths are derived from the schema; the editor flags a wrong one
d(
['address', 'city'],
[
d.props.patchAsync({
tag: (field) => {
// The field in the callback == builder.get(['address', 'city'])
field.form.control!.value; // string
field.form.parent.value; // { city: string; zip: string }
field.form.root.value; // { name; age; price; address; tags } — all exact
// Query other fields and you still get exact types
field.get(['..', 'zip'])!.form.control!.value; // string
field.get(['#', 'age'])!.form.control!.value; // number
field.get(['#', 'tags', 0, 'count'])!.form.control!.value; // number
return 'ok';
},
}),
],
),
]);
Coupling is just as precise — whatever fields you listen to, the callback types exactly those positions:
d(
['address', 'city'],
[
d.valueChange((fn) => {
fn({ list: [undefined, ['..', 'zip'], ['#', 'age']] }).subscribe((s) => {
s.list; // [string, string, number] — exact per position, not any
s.listenFields; // the matching field per position, each with .get() and .form.control
});
}),
],
);
hideWhen / disableWhen / outputChange / class.asyncTop / outputs.mergeAsync … every action with a field callback uses the same set of precise types. Official Valibot actions (v.minLength / v.check / v.transform) can be mixed in directly, and their callback parameters stay precise too.
Framework agnostic: import from @piying/view-angular-core in Angular, from @piying/view-core in Vue / React / Svelte / Solid — the syntax is identical.
typedFieldComponentPipe — component props are narrowed too
typedFieldPipe knows nothing about components: the keys of inputs / outputs are free-form.
typedFieldComponentPipe takes one extra argument: the "component", and locks the keys and value types of inputs / outputs / events / attributes to the properties that component actually has:
- Autocompletion only lists the props / events the component really has
- A wrong prop name or a wrong prop value type is flagged by the editor
- The entry also automatically carries
setComponent(component), so the type used for validation = the component actually rendered
import { typedComponent, typedFieldComponentPipe } from '@piying/view-angular';
const typeDefine = typedComponent({
types: {
// @Input() placeholder?: string; @Input() precision: number
amount: { type: AmountInputComponent },
// @Output() change: EventEmitter<string[]>
tags: { type: TagPickerComponent },
},
});
const merged = typedFieldComponentPipe(schema, typeDefine, (d) => [
d(['price'], 'amount', [
d.inputs.patch({ placeholder: 'Enter an amount' }),
d.inputs.patch({ placeholder: 123 }), // editor error: wrong value type
d.inputs.patch({ palceholder: 'x' }), // editor error: no such prop
d.inputs.patchAsync({
precision: (field) => (field.form.control!.value > 100 ? 0 : 2),
}),
]),
d(['tags'], 'tags', [d.outputs.merge({ change: (value: string[]) => console.log(value) }), d.outputChange((fn) => fn([{ list: undefined, output: 'change' }]))]),
]);
Usage is the same across frameworks; only "how component props map to inputs / outputs" follows each framework's own component model:
| Framework | Mapping rule | Notes |
|---|---|---|
| Angular |
input() / output() / model()
|
The only framework exposing models
|
| Vue 3 | Split from $props, emit names drop the on prefix |
Supports markRaw / lazy loading |
| React | A function prop is an output, names kept as-is | |
| Solid | Same shape as React, built-in keys include classList
|
|
| Svelte 5 | A function prop is an output, Snippets are not counted |
In the component slot you can pass a key registered in types, or a component class directly (including lazy loading). You are not locked out when a component cannot be inferred: inputs / outputs fall back to plain key / value — keys are simply no longer validated, everything else keeps working.
typedFieldPipeis general purpose and works with any frontend framework;
typedFieldComponentPipeis framework specific, depending on which framework you use.
convertToField — manual conversion is equally precise
Every framework provides convertToField. After converting a schema into a field, you can still call field.get(xxx) and get equally accurate types:
const field = convertToField(
() => schema,
undefined,
() => options,
);
field.get(['address', 'zip'])!.form.control!.value; // string
field.get(['tags', 0, 'count'])!.form.control!.value; // number
field.form.control!.value; // the output type of the whole schema
Manual binding — the path is remembered by the type
In manual mode, the path you bind is remembered by the type as well. In Angular that is PiyingFieldControlBindDirective:
<input [formControl]="root()" [path]="['address', 'city']" #city="formControl" />
The city template reference variable:
city.field$$(); // the field type for the path ['address', 'city']
city.fieldControl$$(); // its control, .value is string
city.summaryList$$(); // validation error summary
[path] is autocompleted too — it feeds every valid path of the root schema straight to your editor, the very same set you get from typedFieldPipe. Other frameworks have differently shaped binding directives, with the same capability.
Who is responsible for what
| You write | What you get | Scope |
|---|---|---|
typedFieldPipe(schema, d => [...]) |
Path autocompletion + precise field in callbacks + precise field.get() + precise .value
|
All frameworks (general) |
typedFieldComponentPipe(schema, cfg, d => [...]) |
Everything above + inputs / outputs / events / attributes narrowed to the real component |
One per framework |
convertToField(...) |
The converted field, with precise get() / .value
|
One per framework |
[formControl] / [path] manual binding |
The bound path is remembered by the type, template reference variables are strongly typed | Matching directive per framework |
If you are tired of "renaming one form field means chasing changes through the template, the validation and the types" — this was built for you.
📖 Docs: https://piying-org.github.io/piying-view/ (with an online Playground — edit it and run it right there)


Top comments (0)