Do you validate search queries in your frontend app? For me, the answer is “sometimes”, but if you think of the search query as input for the application the answer should always be instead.
Until now, even when I have done it, I have done it completely wrong. Or maybe it was not completely wrong as at least there was validation applied. Still, there would be ways to improve the readability, and reusability of my solutions.
The problem
Let’s see a real-world example, building a “set password page” for an application. One of the standard flows is that an end user clicks on a link in their email, in the link there is a code
with which we can verify the validity of the request and send the new password. In the following example, I will add an “oobCode” and a “mode” in the URL (for integrating into a firebase reset password flow).
The (wrong?) solution
One of the ways in React (with ReactRouter 6) is the useLocation hook, which yields the search (string, like ?oobCode=123aa3&mode=resetPassword
), and then parses it with the query-string package. (Although you can use useSearchParams hook as well, that won’t yield a plain JS object so it would still need some work afterward.)
const { search } = useLocation();
const query = qs.parse(search);
So next for the validation. What do we want to validate?
- The oobCode exists and it is a string
- The mode exists and it is one of the following:
- resetPassword
- recoverEmail
- verifyEmail
So, something like this would achieve the solution:
enum AuthActionMode {
RESET_PASSWORD = "resetPassword",
RECOVER_EMAIL = "recoverEmail",
VERIFY_EMAIL = "verifyEmail",
}
export function CustomAuthActionPage() {
const [hasInvalidParams, setHasInvalidParams] = useState(false);
const { search } = useLocation();
const query = qs.parse(search);
useEffect(() => {
setHasInvalidParams(false);
if (typeof query.oobCode !== "string") {
setHasInvalidParams(true);
}
if (
typeof query.mode !== "string" ||
(typeof query.mode === "string" && !Object.values(AuthActionMode).includes(query.mode))
) {
setHasInvalidParams(true);
}
}, [query]);
if (hasInvalidParams) {
return <div>Invalid params</div>;
}
return query.mode; // do something with the params
}
Pretty ugly, right? Not to mention it is ugly, but also, we don’t get any “type hints” from Typescript as well, though it does the job, and we can be sure that our query is in the correct format.
The better solution
Then, how can we improve it? Schema validation to the rescue, there are a lot of great JavaScript (and TypeScript) based schema validation libraries, like Joi, Yup, and Zod (and I am sure the list can go on). In the following example, I will use Yup as my personal favorite (I am also using Yup with react-hook-forms for form validation as well) What were my expectations from this hook?
Generic way of working
- Input: Yup schema
- Output: Parsed and validated query with type safety and type hints Error object if there is any.
The first step is to extract the logic from the component into a custom hook, which would already make our component's code a bit cleaner.
export function useParsedQueryString() {
const { search } = useLocation();
const query = qs.parse(search);
return {
query,
};
}
The next step is to add the generic validation schema as input for the hook. Luckily for us Yup has a great Typescript support, so we just need to take advantage of it. It provides a generic type name ObjectSchema, which we can easily parameterize with our generic type.
export function useParsedQueryString<T extends yup.AnyObject>(
validationSchema: yup.ObjectSchema<T>,
);
The only interesting part is that the generic (T) parameter or useParsedQueryString extends the AnyObject, but it is just a type alias for {[k: string]: any;} (any object with string keys).
The next step is to create some variables to hold the current state of the query, the first will be the parsed and validated query, and the second will be the error. Both are nullable — if there is an error, the query object will be null, if there is no error, the error will be null.
export function useParsedQueryString<T extends yup.AnyObject>(
validationSchema: yup.ObjectSchema<T>,
) {
const { search } = useLocation();
const [validatedQuery, setValidatedQuery] = useState<T | null>(null);
const [validationError, setValidationError] = useState<yup.ValidationError | null>(null);
useEffect(() => {
const query = qs.parse(search);
setValidatedQuery(result as T);
}, [search]);
return {
query: validatedQuery,
error: validationError,
};
}
The last piece of code is the actual validation. The Yup schema provides a validateSync method which returns the parsed (and transformed!) object or it throws a ValidationError instance:
useEffect(() => {
const query = qs.parse(search);
try {
const result = validationSchema?.validateSync(query);
setValidatedQuery(result as T);
} catch (e) {
if (e instanceof yup.ValidationError) {
setValidationError(e);
setValidatedQuery(null);
} else {
throw e;
}
}
}, [search, validationSchema]);
With this, everything is ready to use with our shiny new search query validation hook on our component, which is fairly easy.
First, we need to construct the schema:
const queryValidationSchema = yup.object({
oobCode: yup.string().required(),
mode: yup.mixed<AuthActionMode>().oneOf(Object.values(AuthActionMode)),
});
Then we can use it in the hook:
export function CustomAuthActionPage() {
const { query, error } = useParsedQueryString(queryValidationSchema);
if (error) {
return <div>Invalid Params</div>;
}
return query.mode; // do something with the params
}
See how elegant this solution is compared to the one we started with?
And for the last requirement, type safety: with Yup, the type safety for the query result is “free” for us to just use:
As you can see with this fairly simple piece of code, we can increase the safety of our application from possible errors (as we are forced to validate and handle missing and not well-formatted queries) and also increase the developer experience with correct type hints as well.
(+1) Create reusable enum schema
So in the previous examples I have used the following method for validating wether something is an enum value.
yup.mixed<AuthActionMode>().oneOf(Object.values(AuthActionMode)),
But what if we are validating different enums throughout our (possibly large) codebase? The solution can be very cumbersome and has a lot of boilerplate code.
What is a better solution for this? In Yup we can create custom validation methods with yup.addMethod like this:
Yup.addMethod(Yup.mixed, 'enum', function enumSchema(this, enumValue) {
return this.oneOf(Object.values(enumValue));
});
The tricky part is how we can make this in a way that TypeScript can infer the correct types for our schema? Module Augmentation for the rescue!
We should extend the Yup package MixedSchema with a new “enum” named method, which gets an enum and returns a MixedSchema (as this is the pattern for method chaining)
declare module 'yup' {
interface MixedSchema {
enum<T extends EnumValue>(enumValue: T, message?: string): Yup.MixedSchema<T[keyof T]>;
}
}
Yup.addMethod(Yup.mixed, 'enum', function enumSchema<T extends EnumValue>(this: Yup.MixedSchema, enumValue: T) {
return this.oneOf(Object.values(enumValue) as T[keyof T][]);
});
But what is EnumValue in the example? Basically that is just a helper interface to make the parameter generic and can accept an enum:
export interface EnumValue {
[key: string]: string | number;
}
And with this new custom method, our enum validation in the schema becomes more readable, and we still get the correct types for the schema!
const queryValidationSchema = yup.object({
oobCode: yup.string().required(),
mode: yup.mixed().enum(AuthActionMode)
});
TL:DR solution ready for copy and paste
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import * as yup from 'yup';
import qs from 'query-string';
export function useParsedQueryString<T extends yup.AnyObject>(
validationSchema: yup.ObjectSchema<T> | ReturnType<typeof yup.lazy<yup.ObjectSchema<T>>>,
) {
const { search } = useLocation();
const [validatedQuery, setValidatedQuery] = useState<T | null>(null);
const [validationError, setValidationError] = useState<yup.ValidationError | null>(null);
useEffect(() => {
const query = qs.parse(search);
try {
const result = validationSchema?.validateSync(query);
setValidatedQuery(result as T);
} catch (e) {
if (e instanceof yup.ValidationError) {
setValidationError(e);
setValidatedQuery(null);
} else {
throw e;
}
}
}, [search, validationSchema]);
return {
query: validatedQuery,
error: validationError,
};
}
enum AuthActionMode {
RESET_PASSWORD = 'resetPassword',
RECOVER_EMAIL = 'recoverEmail',
VERIFY_EMAIL = 'verifyEmail',
}
const queryValidationSchema = yup.object({
oobCode: yup.string().required(),
mode: yup.mixed<AuthActionMode>().oneOf(Object.values(AuthActionMode)),
});
export function CustomAuthActionPage() {
const { query, error } = useParsedQueryString(queryValidationSchema);
if (error) {
return <div>Invalid Params</div>;
}
return null; // do something with the params
}