Nested data

HTML forms can only handle string values, and the <form> element cannot nest other forms, so there is no standardized way to represent a nested data structure or more complex values like dates. Fortunately, Superforms has a solution for this!

Usage

const { form, enhance } = superForm(data.form, {
  dataType: 'form' | 'json' = 'form'
})

dataType

By simply setting dataType to 'json', you can store any data structure allowed by devalue in the $form store, and you don’t have to worry about failed coercion, converting strings to objects and arrays, etc.

You don’t even have to set a name on the form fields anymore, since the actual data in $form is now posted, not the input fields in the HTML. They are now simply UI components for modifying a data model (as they should be).

Modifying the form store programmatically is easy, you can either assign $form.field = ... directly, which will taint the affected fields. If you want to prevent the form from being tainted, you can use form.update with an extra option:

form.update(
  ($form) => {
    $form.name = "New name";
    return $form;
  },
  { taint: false }
);

The taint options are:

{ taint: boolean | 'untaint' | 'untaint-form' }

Which can be used to not only prevent tainting, but also untaint the modified field(s), or the whole form.

Requirements

The requirements for nested data to work are as follows:

  1. JavaScript is enabled in the browser.
  2. The form has the Superforms use:enhance applied.

Nested errors and constraints

When your schema contains arrays or objects, you can access them through $form as an ordinary object. But how does it work with errors and constraints?

$errors and $constraints actually mirror the $form data, but with every field or “leaf” in the object replaced with string[] and InputConstraints respectively.

Example

Given the following schema, which describes an array of tag objects:

const schema = z.object({
  tags: z
    .object({
      id: z.number().int().min(1),
      name: z.string().min(2)
    })
    .array()
});

const tags = [{ id: 1, name: 'test' }];

export const load = async () => {
  const form = await superValidate({ tags }, zod(schema));
  return { form };
};

You can build a HTML form for these tags using an {#each} loop:

<script lang="ts">
  const { form, errors, enhance } = superForm(data.form, {
    dataType: 'json'
  });
</script>

<form method="POST" use:enhance>
  {#each $form.tags as _, i}
    <div>
      Id
      <input
        type="number"
        data-invalid={$errors.tags?.[i]?.id}
        bind:value={$form.tags[i].id} />
      Name
      <input
        data-invalid={$errors.tags?.[i]?.name}
        bind:value={$form.tags[i].name} />
      {#if $errors.tags?.[i]?.id}
        <br />
        <span class="invalid">{$errors.tags[i].id}</span>
      {/if}
      {#if $errors.tags?.[i]?.name}
        <br />
        <span class="invalid">{$errors.tags[i].name}</span>
      {/if}
    </div>
  {/each}
  <button>Submit</button>
</form>

Note that we’re using the index of the loop, so the value can be bound directly to $form. You can’t use the each loop variable, hence the underscore. This is what it looks like:

Id
Name
Id
Name

Arrays with primitive values

It’s possible to post multiple HTML elements with the same name, so you don’t have to use dataType: 'json' for arrays of primitive values like numbers and strings. Just add the input fields, all with the same name as the schema field, which can only be at the top level of the schema. Superforms will handle the type coercion to array automatically, as long as the fields have the same name attribute:

export const schema = z.object({
  tags: z.string().min(2).array().max(3)
});
<script lang="ts">
  const { form, errors } = superForm(data.form);
</script>

<form method="POST">
  <div>Tags</div>
  {#if $errors.tags?._errors}
    <div class="invalid">{$errors.tags._errors}</div>
  {/if}

  {#each $form.tags as _, i}
    <div>
      <input name="tags" bind:value={$form.tags[i]} />
      {#if $errors.tags?.[i]}
        <span class="invalid">{$errors.tags[i]}</span>
      {/if}
    </div>
  {/each}

  <button>Submit</button>
</form>

To summarize, the index i of the #each loop is used to access $form.tags, where the current values are (you cannot use the loop variable), and then the name attribute is set to the schema field tags, so its array will be populated when posted.

This example, having a max(3) limitation of the number of tags, also shows how to display array-level errors with the $errors.tags._errors field.

Validation schemas and nested paths

Validation libraries like Zod can refine the validation, the classic example is to check if two password fields are identical when updating a password. Usually there’s a path specifier for setting errors on those fields in the refine function:

const confirmPassword = z
  .object({
    password: z.string(),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords don't match",
    path: ["confirm"], // path of error
  });

This works fine for top-level properties, but for nested data you must usually specify that path as an array, each segment in its own element, not as a string path as you can do in the FormPathLeaves type!

// OK:
path: ['form', 'tags', 3]
// Will not work with Zod refine and superRefine:
path ['form.tags[3]']