In this post I will show you my approach on scalable user input validation. Yup is the essential library to help me achieve this goal. I also use express, react and formik.
One function to validate - one to handle them all
The main helper funcions are validateInput
and handleFieldErrors
. You may define them their own package because validateInput
is useful for client and server side projects.
It receives a yup-Schema and any input and will return the input if it was valid or throw a ValidationError
if there is any:
export const validateInput = async <T>(
schema: ObjectSchema<any>,
input: any
): Promise<T> => {
await schema.validate(input, { abortEarly: false });
return schema.cast(input);
};
The function is quite simple, the only important detail here, is the schema.cast(input)
and the generic return type that will help to get the right typescript type for better auto-completion. More information on this magic can be found in the yup documentation.
Client-side Usage
To use it you just have to define you schema and await
it:
const schema = object({ name: string().required() })
const validatedInput = await validateInput<Asserts<typeof schema>>(
schema,
notValidatedInupt
);
Note that we feed the generic with Asserts<>
, which is exported by yup
.
In formiks onSubmit
callback you can catch the error from validateInput
and map them to the fields:
// onSubmit={async (values, { setFieldError }) => {
try {
const schema = object({
name: string().required(),
age: number()
.transform((value, original) =>
original == null || original === "" ? undefined : value
)
.required(),
});
const validatedInput = await validateInput<Asserts<typeof schema>>(
schema,
values
);
setResult(`${validatedInput.name} is now ${validatedInput.age}`);
} catch (error) {
if (error instanceof ValidationError) {
error.inner.forEach(({ path, message }) => {
if (path != null) {
setFieldError(path, message);
}
});
}
}
Of course you can outsource the catch
part, but do not forget to catch other errors!
export const handleFieldErrors = (
error: any,
setFieldError: (fieldKey: string, errorMessage: string) => void
) => {
if (error instanceof ValidationError) {
error.inner.forEach(({ path, message }) => {
if (path != null) {
setFieldError(path, message);
}
});
} else {
throw error;
}
};
Server side usagae
Its is basically the same, but there is one caveat:
app.post("/", async (req, res) => {
try {
const bodySchema = object({
name: string().required().notOneOf(["admin"]),
age: number()
.transform((value, original) =>
original == null || original === "" ? undefined : value
)
.required(),
});
const { age, name } = await validateInput<Asserts<typeof bodySchema>>(
bodySchema,
req.body
);
return res.json({ age, name });
} catch (error) {
res.status(400);
res.json(error);
}
});
The instanceof
will no longer work since the backend will just return plain JSON to our client. So if your want to use the errors from your node backend, you either have to catch them, map them to a ValidationError
and throw them to handleFieldErrors
or give some trust to Typescript and yup like so:
if (error instanceof ValidationError || error.inner != null) {
//...
}
You can also use this pattern to validate req.params
or req.query
. Because it will return the valid and typescript safe input, you will not have a hard time finding the properties with your auto-completion.
Combined Powers
As a result you can have both client and server side validation or just server side validation, without changing the catch handler.
App.js handling backend and frontend validation errors
const submitLocal = async (values: any) => {
await new Promise((resolve) => setTimeout(resolve, 100));
const schema = object({
name: string().required(),
age: number()
.transform((value, original) =>
original == null || original === "" ? undefined : value
)
.required(),
});
const validatedInput = await validateInput<Asserts<typeof schema>>(
schema,
values
);
return `${validatedInput.name} is now ${validatedInput.age}`;
};
const submitBackend = async (values: any) => {
const response = await fetch(`/`, {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const error = await response.json();
throw error;
}
const { age, name } = await response.json();
return `${name} is now ${age}`;
};
export default function App() {
const [result, setResult] = useState<string | void>();
return (
<div className="App">
<Formik
initialValues={{ age: "", name: "" }}
onSubmit={async (values, { setFieldError }) => {
setResult();
try {
await submitLocal(values);
const nextResult = await submitBackend(values);
setResult(nextResult);
} catch (error) {
handleFieldErrors(error, setFieldError);
}
}}
>
// fields and friends ;)
Notes
the number transform hack
.transform((value, original) =>
original == null || original === "" ? undefined : value
)
Since required
will only grumble on null
, undefined
or (if it is a string()
) ''
, but number()
will cast to a valid number or NaN
. So you might want to check the original Value to prevent NaN
in your validated input (more information).
The End
Thanks for reading this post. If you want you can leave some feedback down below since this is my first post I would appreciate it 🙏.
Top comments (0)