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.