vee-validate
vee-validate copied to clipboard
Composable API: Better static typing support
Is your feature request related to a problem? Please describe.
Currently, there is no connection between the schema and the useField methods. Consider for example, the following code snippet taken from the documentation
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8),
});
useForm({ validationSchema: schema });
const { value: email, errorMessage: emailError } = useField('email');
const { value: password, errorMessage: passwordError } = useField('password');
If one renames email in the schema but forget to do this in the useField call, this is only noticed on runtime when the useField method is invoked. Moreover, in order to get proper type support for the email variable, one needs to use useField<string>('email') if I'm not mistaken.
So the problem is that there is essentially no connection between the schema and the useField method.
Describe the solution you'd like
Add a useField method to PublicFormContext so that the example above can be written as
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8),
});
const form = useForm({ validationSchema: schema });
const { value: email, errorMessage: emailError } = form.useField('email');
const { value: password, errorMessage: passwordError } = form.useField('password');
Now, the useForm method can get enough typing information and pass this to form.useField. The upshot is that one gets type errors if the parameter of useField is not defined in the schema. At the same time, the type of the variable email could also automatically be inferred to be string, since this is its type in the schema.
why not form.email.useField or smth like that?
That's a nice suggestion, or even shorter const { value: email, errorMessage: emailError } = form.email without the useField at all.
Howdy all;
I also experienced some friction with Vue 3 compositional API and TypeScript experience around the current APIs. There seemed to be a fair bit of boilerplate and I didn't like the type casting via generics on useField. I also was getting invalid types on the callback handler for handleSubmit.
I ended up writing a userland utility to help with this.
Util
import { toFormValidator } from "@vee-validate/zod";
import { SubmissionContext, useField, useForm } from "vee-validate";
import { reactive, Ref } from "vue";
import * as zod from "zod";
type MaybeRef<T> = Ref<T> | T;
// I had to reimpliment this interface as it is not exported by vee-validate
interface FieldOptions<TValue = unknown> {
initialValue?: MaybeRef<TValue>;
validateOnValueUpdate: boolean;
validateOnMount?: boolean;
bails?: boolean;
type?: string;
valueProp?: MaybeRef<TValue>;
checkedValue?: MaybeRef<TValue>;
uncheckedValue?: MaybeRef<TValue>;
label?: MaybeRef<string | undefined>;
standalone?: boolean;
}
// I had to reimpliment this interface as it is not exported by vee-validate
interface FormOptions<TValues extends Record<string, any>> {
initialValues?: MaybeRef<TValues>;
initialErrors?: Record<keyof TValues, string | undefined>;
initialTouched?: Record<keyof TValues, boolean>;
validateOnMount?: boolean;
}
export const useZodForm = <
Schema extends zod.ZodObject<any> = any,
Values extends zod.infer<Schema> = any,
>(
schema: Schema,
options: Omit<FormOptions<Values>, "validationSchema"> = {},
) => {
const validationSchema = toFormValidator(schema);
const form = useForm<Values>({
...options,
validationSchema: validationSchema as any,
});
return {
...form,
useField: <Field extends keyof Values, FieldType extends Values[Field]>(
field: Field,
opts?: FieldOptions<FieldType>,
) => reactive(useField<FieldType>(field as string, undefined, opts)),
handleSubmit: (
cb: (values: Values, ctx: SubmissionContext<Values>) => unknown,
) => form.handleSubmit(cb),
};
};
Note how I return a wrapped and typed version of useField, and also a wrapped and typed version of handleSubmit. I additionally wrap the useField result with reactive so I can just return out the entire value rather than having to destructure and rename value and errorMessage for each field.
You can also still pass in all the options to useField and get all the other properties expected from the result of useField and useForm.
Examples
Usage with just useZodForm
export default defineComponent({
name: "RegisterForm",
setup() {
const { handleSubmit, errors, values } = useZodForm(
zod.object({
displayName: zod
.string()
.nonempty({ message: "Display name is required" }),
email: zod
.string()
.email({ message: "Must be a valid email" })
.min(1, "Email name is required"),
password: zod.string().min(1, "Password is required"),
}),
);
const onSubmit = handleSubmit(async (values) => {
console.log(values);
});
return {
errors,
values,
onSubmit,
};
},
});
Usage with useZodForm and useField
export default defineComponent({
name: "RegisterForm",
setup() {
const { handleSubmit, useField } = useZodForm(
zod.object({
email: zod
.string()
.min(1)
.email({ message: "Must be a valid email" }),
password: zod.string().min(1),
}),
);
const email = useField("email");
const password = useField("password");
const onSubmit = handleSubmit(async (values) => {
console.log(values);
});
return {
email,
onSubmit,
password,
};
},
});
Typing Demo
Fields:
Handle submit callback:
Render function data:
Or you can use my variant of zod:
export const useZodForm = <Schema extends zod.ZodObject<AnyType>, Values extends zod.infer<Schema>>(
schema: Schema,
initialValues: Values
) => {
const form = useForm({
initialValues,
validationSchema: toFormValidator(schema),
});
const keys = Object.keys(form.values) as Array<keyof typeof form.values>;
const getField = <Field extends keyof typeof form.values>(field: Field) =>
computed({
get() {
return form.values[field];
},
set(value: typeof form.values[Field]) {
form.setFieldValue(field, value);
},
});
const fields = keys.reduce(
(acc, key) => ({
...acc,
[key]: getField(key),
}),
{} as { [key in keyof typeof form.values]: WritableComputedRef<typeof form.values[key]> }
);
return { form, fields };
};
and use it like this:
<template>
<input v-model="fields.firstName" />
<input v-model="fields.lastName" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as z from 'zod';
import { useZodForm } from '@/shared/hooks';
export default defineComponent({
setup() {
const { form, fields } = useZodForm(
z.object({
firstName: z.string(),
lastName: z.string(),
}),
{ firstName: '', lastName: '' }
);
form.handleSubmit(values => {
console.log(values);
});
return { fields };
},
});
</script>
Connection between form and useField method is more than welcome.
For yup I'm using this hook:
(need to upgrade yup to beta version)
import { Ref } from 'vue';
import { useField, useForm } from 'vee-validate';
import { ObjectSchema, InferType } from 'yup';
interface Form<Schema, Values> {
validationSchema: Schema;
initialValues: Values;
}
type Fields<F> = { [K in keyof F]: Ref<F[K]> };
export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({
initialValues,
validationSchema,
}: Form<Schema, Values>) => {
const form = useForm({
initialValues,
validationSchema,
});
const keys = Object.keys(form.values) as (keyof Values)[];
const fields = keys.reduce(
(acc, key) => ({
...acc,
[key]: useField(key).value,
}),
{} as Fields<Values>
);
return { form, fields };
};
Up. I really don't understand why this lib has support for zod if it's to get no types out of validation out of the box. Are the above workarounds the only solutions as of now?
For yup I'm using this hook:
(need to upgrade yup to beta version)
import { Ref } from 'vue'; import { useField, useForm } from 'vee-validate'; import { ObjectSchema, InferType } from 'yup'; interface Form<Schema, Values> { validationSchema: Schema; initialValues: Values; } type Fields<F> = { [K in keyof F]: Ref<F[K]> }; export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({ initialValues, validationSchema, }: Form<Schema, Values>) => { const form = useForm({ initialValues, validationSchema, }); const keys = Object.keys(form.values) as (keyof Values)[]; const fields = keys.reduce( (acc, key) => ({ ...acc, [key]: useField(key).value, }), {} as Fields<Values> ); return { form, fields }; };
How would you return other field attributes instead of value only? For example I'd need field meta too. Better yet, get typed useField method as return value instead of fields.
I really don't understand why this lib has support for zod if it's to get no types out of validation out of the box
Previously, vee-validate was able to infer the form values type from the validation schema however it wasn't perfect because:
- Actual values type is not the same as the type of the accepted values (some fields may be undefined while others are nullable)
- Using schema inference for yup (or zod) means their types need to be present in the project. forcing everybody to install
yuporzodeven if they don't need them. Remember that those aren't the only ways to write validation rules, so I need to find a better compromise here. - Nested fields are hard to fully type, especially when an array is involved.
If everybody was using yup or zod then maybe. But that's just not the case. I'm trying to find the time to re-explore this in a way that works for both type of users.
For yup I'm using this hook:
(need to upgrade yup to beta version)
import { Ref } from 'vue'; import { useField, useForm } from 'vee-validate'; import { ObjectSchema, InferType } from 'yup'; interface Form<Schema, Values> { validationSchema: Schema; initialValues: Values; } type Fields<F> = { [K in keyof F]: Ref<F[K]> }; export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({ initialValues, validationSchema, }: Form<Schema, Values>) => { const form = useForm({ initialValues, validationSchema, }); const keys = Object.keys(form.values) as (keyof Values)[]; const fields = keys.reduce( (acc, key) => ({ ...acc, [key]: useField(key).value, }), {} as Fields<Values> ); return { form, fields }; };
This is nice, but doesnt seem to work with nested objects inside your schema :/
4.8 is released with automatic schema inference for input/output type with either yup or zod. Check this for more information
This works very well, thanks!
Looks like what I use with react-hook-form but what about nested field autocompletion ?
So far in react, I'm able to do:
const { register, control, handleSubmit, control, formState: { dirtyFields, errors } } = useFormWithSchemaBuilder(
(yup) =>
yup.object({
firstname: yup.string().required("some message"),
})
);
// Render with register
<CustomInput
register={register("firstname")}
inputType="text"
placeholder={t("contact_form.firstname_placeholder")}
containLabel={{ label: t("contact_form.firstname_placeholder"), labelTransition: true }}
/>
// Render with controls (for controlled components)
<InputText
control={control}
name="firstname" // I get auto-completion on "firstName"
textContentType="emailAddress"
keyboardType="email-address"
autoComplete="email"
error={errors.firstname?.message}
autoCorrect={false}
autoCapitalize="none"
/>
import { AppText } from "@modules/ui/components/Text";
import React, { RefObject } from "react";
import { Control, Controller, Path } from "react-hook-form";
import { TextInput, View, TextInputProps } from "react-native";
import { useTailwind } from "tailwind-rn";
interface BaseInputProps {
label?: string;
rightIcon?: React.ReactNode;
error?: string;
}
interface ActiveInputProps<T> extends BaseInputProps {
control: Control<T>;
name: Path<T>;
inputRef?: RefObject<TextInput>;
}
interface DisabeldInputProps extends BaseInputProps {
value: string;
}
type Props<T> = ActiveInputProps<T> | DisabeldInputProps;
export function InputText<T>(
props: Props<T> &
Pick<
TextInputProps,
| "autoComplete"
| "keyboardAppearance"
| "keyboardType"
| "textContentType"
| "secureTextEntry"
| "passwordRules"
| "autoCapitalize"
| "autoCorrect"
| "placeholder"
| "onSubmitEditing"
| "returnKeyType"
>,
) {
const tw = useTailwind();
const { label, rightIcon, error } = props;
// Readonly field
if ("value" in props) {
return (
<>
{label && (
<AppText style={tw("text-text-strong mb-2")}>{label}</AppText>
)}
<View
style={tw(
"flex flex-row items-center rounded-lg border border-surface-strong bg-surface-base",
)}
>
<TextInput
style={tw("h-12 flex-grow px-4 py-2.5 text-text-soft font-Medium")}
value={props.value}
editable={false}
/>
{rightIcon && <View style={tw("mx-4")}>{rightIcon}</View>}
</View>
{error && (
<AppText size="sm" style={tw("text-danger-base mt-1")}>
{error}
</AppText>
)}
</>
);
}
const { placeholder, control, name, inputRef, ...rest } = props;
return (
<>
{label && <AppText style={tw("text-text-strong mb-2")}>{label}</AppText>}
<View
style={tw(
"flex flex-row items-center rounded-lg border border-surface-strong bg-surface-base",
)}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={tw("h-12 flex-grow px-4 py-2.5 text-text-soft")}
onBlur={onBlur}
onChangeText={onChange}
value={value as string}
placeholder={placeholder}
ref={inputRef}
{...rest}
/>
)}
name={name}
/>
{rightIcon && <View style={tw("mx-4")}>{rightIcon}</View>}
</View>
{error && (
<AppText size="sm" style={tw("text-danger-base mt-1")}>
{error}
</AppText>
)}
</>
);
}
This is possible because we have
control: Control<T>;
name: Path<T>;
types exported, how to achieve same with vee ?