DEV Community

wszgrcy
wszgrcy

Posted on

Piying-View: write forms with a schema, field types inferred end to end

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 a field, 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 '';
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode
  • inputs is typed as Record<string, any>: keys are free-form, value types are not checked
  • The field in callbacks is a generic shape: get() cannot resolve an accurate type, .value is any
  • A typo like palceholder, or using a number where a string belongs, 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: field is the exact field type for that path, not any
  • Typed values: field.form.control.value is the output type inferred from the schema; same for parent / 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';
        },
      }),
    ],
  ),
]);
Enter fullscreen mode Exit fullscreen mode

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
      });
    }),
  ],
);
Enter fullscreen mode Exit fullscreen mode

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' }]))]),
]);
Enter fullscreen mode Exit fullscreen mode

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.

typedFieldPipe is general purpose and works with any frontend framework;
typedFieldComponentPipe is 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
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

[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)

📦 Repo: https://github.com/piying-org/piying-view

Top comments (0)