DEV Community

loading...
Cover image for Validate like a pro everywhere with yup

Validate like a pro everywhere with yup

buschco profile image buschco ・3 min read

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 at this is working repo here.

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);
};
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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) {
  //...
}
Enter fullscreen mode Exit fullscreen mode

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 ;) 

Enter fullscreen mode Exit fullscreen mode

Notes

the number transform hack

.transform((value, original) =>
  original == null || original === "" ? undefined : value
)
Enter fullscreen mode Exit fullscreen mode

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 🙏.

Discussion (0)

pic
Editor guide