Designing forms with the ValibotForm Component
01 Jul 2025

Previously we explored the Zod Form Component, until I realized that it actually generates a much bigger bundle size even if we use just a handful of functions. The Valibot library solves this by making use of ES module imports with treeshaking during build bundling. Therefore it is more suitable for client-side applications.

The stackblitz repo is here. We will reference it in this post instead of having the full code snippet here. This post will explore the mental model when using the ValibotForm so we can design our form more effectively and cleanly.

Mental model when designing form

As I worked on numerous forms in my day job, there is a mental model now that I used when it comes to designing a form. This approach applies very effectively for small and large, simple and complex form structure. The goal is to maintain a consistent design pattern for form modeling especially in a team ran codebase.

Clean model Type

Before starting to code, the first step is to think of the “clean” or “valid” state of the model we are modelling. In TypeScript, we would think of the Type for a feedback form like this for example, modelling the required and optional fields:

type Feedback = {
  name: string;
  email: string;
  like: "ambience" | "food" | "service" | "price" | "others";
  others?: string | null | undefined;
};

Note that I did not include the suffix like Form in the Type naming because here we are thinking of the “valid” state of the model, not the Form model. With that in mind, we can start to write the Valibot equivalent.

Type only Schema

export const FeedbackSchema = v.object({
  name: v.string(),
  email: v.string(),
  like: v.picklist(["ambience", "food", "service", "price", "others"]),
  others: v.nullish(v.string()),
  agreeTerms: v.boolean(),
});

So if we do type inferencing, you should get the same Type shape as the one above.

type Feedback = v.InferInput<typeof FeedbackSchema>;

Full Schema with validations

Next we will augment the schema with validations wrapping with v.pipe() and chaining with actions like v.minLength() and v.email(), together with error messages so that it becomes a proper schema.

export const FeedbackSchema = v.pipe(
  v.object({
    name: v.pipe(
      v.string("Name is required."),
      v.trim(),
      v.minLength(3, "Name is required.")
    ),
    email: v.pipe(
      v.string("Email is required."),
      v.trim(),
      v.email("Valid Email is required.")
    ),
    like: v.picklist(
      ["ambience", "food", "service", "price", "others"],
      "Please select one."
    ),
    others: v.nullish(v.string()),
    agreeTerms: v.boolean("Please agree the terms."),
  }),
  v.forward(
    v.check((data) => {
      if (data.like === "others") {
        return (data.others?.length ?? 0) > 0;
      }
      return true;
    }, "Please enter a feedback category."),
    ["others"]
  )
);

Form Type

Once the schema is done, only then we will create the form type by “null-ing” the properties. We do that because we want to create an initial state of the form with unfilled values, creating a better UX by only triggering the validation when the dirty flag is set.

From the Form Type we inferred from FeedbackSchema, we can transform it to create the Form Type using the magic of utility types.

type Nullish<T> = T | null | undefined;

type FeedbackForm = Omit<Feedback, "name" | "email" | "like"> & {
  name: Nullish<Feedback["name"]>;
  email: Nullish<Feedback["email"]>;
  like: Nullish<Feedback["like"]>;
};

Those with keen eyes might asked: “Why not use the Partial<> generic type?“. The reason for not using the Partial<> type is because there are times when some fields we would want to be defaulted at an initial value, instead of all fields being undefined. In our Feedback Form example, the agreeTerms will be set to false by default, making the form to provide a checkbox to bind to. Moreover, the Partial<> type makes all fields optional, which is not what we are going for here. Therefore, when creating the Form Type, we will need to think of the type of input element and whether a default value should be set.

Using ModifyDeep utility Type

When we have a more complex model with nested child schemas, using the Omit Type can be verbose as you would need to specify specifically which properties we need to omit. Thankfully, using the ModifyDeep Type found in this gist, we can simplify the syntax.

type AnyObject<T = any> = Record<string, T>;

/** Makes properties deeply partial and turns each leaf property into `any`, allowing for type overrides by narrowing `any`. */
type DeepPartialAny<T> = {
  [P in keyof T]?: T[P] extends AnyObject ? DeepPartialAny<T[P]> : any;
};

type ModifyDeep<A, B extends DeepPartialAny<A>> = {
  // https://stackoverflow.com/a/74216680/985454
  [K in keyof A | keyof B]: K extends keyof A
    ? K extends keyof B
      ? A[K] extends AnyObject
        ? B[K] extends AnyObject
          ? B[K] extends readonly any[]
            ? B[K]
            : ModifyDeep<A[K], B[K]>
          : B[K]
        : B[K]
      : A[K]
    : B[K];
};

Improving the FeedbackForm type using the ModifyDeep utility type instead, we have the following:

export type FeedbackForm = ModifyDeep<
  Feedback,
  {
    name: Nullish<Feedback["name"]>;
    email: Nullish<Feedback["email"]>;
    like: Nullish<Feedback["like"]>;
  }
>;

Well, on first look, it might not make a huge difference if we compare to using the Omit<> Type earlier, however if we are having many more nested child schemas, the ModifyDeep<> utility Type is definitely more concise to read and interpret. With the FeedbackForm defined, we can start to incorporate it in our Vue template using the ValibotForm component.

Using the ValibotForm component

Similar to what we did on the ZodForm component, we will do the same for ValibotForm component. The usage is the same. We have the ValibotForm accepting 2 props: the schema and the actual form value. And using ValibotFormField to wrap individual input elements to control the ‘dirty’ flag, as well as, displaying the error message with respect to that field.

Here is the basic usage (simplified for explanation) in the Vue component:

<script setup lang="ts">
import { FeedbackSchema } from './schema';

const feedbackFormModel: FeedbackForm = {
  name: null,
  email: null,
  like: null,
  others: null,
  agreeTerms: false,
};

function onFeedbackSubmitted() {
    ...
}

</script>
<template>
  <ValibotForm
    :schema="FeedbackSchema"
    :value="feedbackFormModel"
    v-slot="{ isFormValid }"
    @form-submit="onFeedbackSubmitted">
    <ValidbotFormField v-slot="{ setDirty, errorMessages }" :path="['name']">
        <input v-model="form.name" @blur="setDirty(true)" />
        {{ errorMessages }}
    </ValibotFormField>
    <ValidbotFormField v-slot="{ setDirty, errorMessages }" :path="['email']">
        <input v-model="form.contact" @blur="setDirty(true)" />
        {{ errorMessages }}
    </ValibotFormField>
    ...
    <button type="submit" :disabled="!isFormValid">Submit</button>
  </ValibotForm>
</template>

Retrieving the parsed data

By default, the ValibotForm is rendered as a native HTML <form> element, and internally it is listening to the native submit event. The component captures this event internally and emits the form-submit event, alongside Valibot’s parsed result. As such, we can retrieve the parsed data using result.output.

function onFormSubmitted({
  success,
  output,
}: v.SafeParseResult<v.GenericSchema<Feedback>>) {
  if (success) {
    feedback.value = output;
  } else {
    // show errors
    feedback.value = undefined;
  }
}

In order to get the inferred type from the result.output, you would need to check whether the result.success is true first. This is so that TypeScript can infer the output properly.

Conclusion

I really like how consistent the code can now be when it comes to designing forms with a schema-first library like Valibot/Zod, and together with the custom ValibotForm component, easily and systematically code any type of form model and structure. We will explore a more interesting use-case, like dynamic form, in a future post.