Skip to content

Support parent-level error display for deeply nested field values #153

Description

@ap32

Problem

Some fields are one field from the form/UI perspective, but their value is a nested structure.

Examples:

const Schema = v.object({
  // Tiptap / ProseMirror JSON
  content: TiptapContentSchema,

  // Editor.js output
  article: EditorJsOutputSchema,

  // Reka UI Tags Input or similar tag editor
  tags: v.array(TagSchema),

  // File upload field with per-file metadata
  files: v.array(FileWithMetadataSchema),
});

These are usually not separate form fields. They are one custom field connected with useField() or <Field>:

<Field :of="form" :path="['content']" v-slot="field">
  <TiptapEditor v-model="field.input" />

  <div v-if="field.errors?.[0]">
    {{ field.errors[0] }}
  </div>
</Field>

<Field :of="form" :path="['tags']" v-slot="field">
  <TagsInputRoot v-model="field.input">
    <!-- tags input implementation -->
  </TagsInputRoot>

  <div v-if="field.errors?.[0]">
    {{ field.errors[0] }}
  </div>
</Field>

<Field :of="form" :path="['files']" v-slot="field">
  <FileUploadField v-model="field.input" />

  <div v-if="field.errors?.[0]">
    {{ field.errors[0] }}
  </div>
</Field>

The problem is that validation can fail deeper inside the value:

content.content.0.content.1.text
article.blocks.3.data.items.0.content
tags.2.label
files.0.metadata.crop.width

But from the form perspective, the error often needs to be displayed at the parent field:

content
article
tags
files

The user does not interact with content.content.0.content.1.text as a separate field. They interact with one Tiptap editor.

The same applies to Editor.js, tags input, and upload fields with nested metadata.

Current limitation

Formisch already has getDeepErrors and getDeepErrorEntries, but for this use case the field usually only needs the first relevant deep error.

Using getDeepErrors(...)[0] is not ideal because it suggests collecting all descendant errors just to render one message.

The missing primitive is a way to ask:

What is the first error for this field, including errors below this field path?

For example:

getDeepError(form, { path: ['content'] })
getDeepError(form, { path: ['article'] })
getDeepError(form, { path: ['tags'] })
getDeepError(form, { path: ['files'] })

Expected behavior:

  1. Check direct errors at the requested path.
  2. Search descendant errors.
  3. Return the first error.
  4. Stop searching after the first match.

Then a field could be written like this:

<Field :of="form" :path="['content']" v-slot="field">
  <TiptapEditor v-model="field.input" />

  <div v-if="field.errors?.[0] || getDeepError(form, { path: ['content'] })">
    {{ field.errors?.[0] ?? getDeepError(form, { path: ['content'] }) }}
  </div>
</Field>

A related helper could also be useful:

getDeepErrorEntry(form, { path: ['content'] })

returning the original path together with the message:

{
  path: ['content', 'content', 0, 'content', 1, 'text'],
  error: 'Content is required'
}

Possible Valibot-level solution

This could also be solved at the schema level.

Valibot could provide a way to mark a schema as an error boundary / error surface:

const Schema = v.object({
  content: v.errorBoundary(TiptapContentSchema, {
    mode: 'first',
  }),

  article: v.errorBoundary(EditorJsOutputSchema, {
    mode: 'first',
  }),

  tags: v.errorBoundary(v.array(TagSchema), {
    mode: 'first',
  }),

  files: v.errorBoundary(v.array(FileWithMetadataSchema), {
    mode: 'first',
  }),
});

Then an issue at:

tags.2.label

could be surfaced as:

tags

For example, flattened errors could become:

{
  nested: {
    tags: ['Tag label is required']
  }
}

instead of only:

{
  nested: {
    'tags.2.label': ['Tag label is required']
  }
}

Possible behavior:

type ErrorBoundaryOptions = {
  mode?: 'first' | 'all' | number;
  message?: string | ((issues: BaseIssue[]) => string);
  preserveIssues?: boolean;
};

Examples:

v.errorBoundary(schema, { mode: 'first' })

Surface only the first deep issue at the parent path.

v.errorBoundary(schema, { mode: 'all' })

Surface all deep issue messages at the parent path.

v.errorBoundary(schema, { message: 'Invalid content' })

Use one parent-level message instead of exposing internal paths/messages.

Suggested direction

This could be solved in Formisch, Valibot, or both.

On the Formisch side, an optimized helper like this would solve the immediate UI problem:

getDeepError(form, { path })
getDeepErrorEntry(form, { path })

On the Valibot side, an error boundary / error surface would solve it semantically:

v.errorBoundary(schema, { mode: 'first' })

I think the Valibot-level solution is the cleaner long-term solution because the schema author knows whether a nested value should behave as one field.

The Formisch-level helper is still useful because field components often need a simple and performant way to display the first descendant error for a field.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions