View
logo
logo

Contatti

Labs Software Development 22 August 2023

Typed object parsing with Yup cast

Writen by odc-admin

comments 0

React Yup Cast

While working on a project based on React, any framework using it, or even just plain JavaScript, it’s not uncommon to have to fetch data from some external source.

There are plenty of potential data sources you may use: web APIs, SDKs, documents from the file system, the browser window’s localStorage, a query string. For instance, you could have to retrieve data serialized in some text form, perhaps even data you previously saved somewhere yourself in order to store a preference for the user of your application.

Retrieving this kind of information, regardless of how you do it, is usually easy enough: you just query the data source, get the item or the list you’re looking for and that’s it.

.…or is it?

JavaScript is a dynamically typed language and, even though you could and probably should use TypeScript – like we do – as a helping tool to identify type errors before it’s too late, variables and properties will still have dynamic typing at runtime. For this reason, you may find yourself dealing with values you don’t expect, especially when querying text-based data sources.

Typically, this happens with numeric fields, but every kind of non-text value is potentially affected: you have a numeric value stored somewhere, you fetch it from the data source, and you use it in a strict comparison or in some function expecting to be playing with a number, only to then find out it’s actually a string, often leading to sneaky and obscure bugs. Let’s see an example of that.

Here’s a very common situation for anyone working in web development: we’re creating a React web app that stores a delicious pizza in the query string, and later retrieves its data to display them to the user. Boy, if I had a nickel.

Let’s start by instantiating a create-react-project with TypeScript, just the way we like it, and let’s get to work.

npx create-react-app react18-typed-parsing --template typescript

We’ll add a few libraries, as well. Specifically Qs, to serialize and deserialize objects in the query string, along with react-router and react-router-dom o manipulate said query string, of course with the related type declarations.

npm install qs react-router react-router-domnpm install -D @types/qs

First off, let’s create a data model for our pizza, with a numeric ID and a pair of text fields.

// src/models/Pizza.ts
export interface Pizza {
  id: number

  name: string

  description?: string
}

Our web app will consist mainly of two components.

  • PizzaWriter will take a Pizza and set it in the query string.
  • PizzaReader will wait for a Pizza to appear in the query string. As soon as that happens, it will instantly consume the thing and show its data to the user, kind of like me when the Just Eat rider arrives.

The components will be inside the container PizzaWrapper

// src/pages/PizzaWrapper.tsx
import React, {ReactElement} from 'react';
import PizzaWriter from '../components/PizzaWriter';
import PizzaReader from '../components/PizzaReader';

const PizzaWrapper = (): ReactElement | null => {
  return (
    <>
      <PizzaWriter/>
      <PizzaReader/>
    </>
  );
}

export default PizzaWrapper;

…which will act as the root component.

// src/App.tsx
import React from 'react';
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import './App.css';
import PizzaWrapper from './pages/PizzaWrapper';

const router = createBrowserRouter([
{
    path: '/',
    element: <PizzaWrapper/>,
  },
]);

function App() {
  return (
    <RouterProvider router={router}/>
  );
}

export default App;

PizzaWriter is pretty simple: when a button is clicked, it serializes a Pizza object using Qs and sets it in the query string using the hook useSearchParams from react-router-dom. useSearchParams.

// src/components/PizzaWriter.tsx
import React, {ReactElement} from 'react';
import {useSearchParams} from 'react-router-dom';
import Qs from 'qs';
import {Pizza} from '../models/Pizza';

// The pizza to be delivered via query string.
const pizzaToWrite: Pizza = {
  id: 1,
  name: 'Pepperoni',
  description: 'So good!',
};

const PizzaWriter = (): ReactElement => {
  // Function to manipulate the query string.
  const [, setSearchParams] = useSearchParams();

  // Saving the pizza in the query string on click.
  const savePizzaInQueryString = (): void => {
    setSearchParams(Qs.stringify(pizzaToWrite));
  }

  return (
    <div className={'querystring-writer'}>
      <h1>PizzaWriter</h1>
      <button onClick={savePizzaInQueryString}>
        Save pizza in query string
      </button>
    </div>
);
}

export default PizzaWriter;

PizzaReader is where things start to get tricky. Basically, we want to listen to the query string, again using useSearchParams, so we’re ready to get a Pizza and set it in the state. b The component is expecting a pepperoni pizza, so let’s also check the ID to make sure the item is exactly the one that was ordered.

The question now is: how do we make sure the object we’re getting is actually a Pizza?

// src/components/PizzaReader.tsx
import React, {ReactElement, useEffect, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import Qs from 'qs';
import {Pizza} from '../models/Pizza';

// The pizza we're expecting to find in the query string.
const pizzaToRead: Pizza = {
  id: 1,
  name: 'Pepperoni',
  description: 'So good!',
};

const PizzaReader = (): ReactElement | null => {
  const [searchParams] = useSearchParams();

  // The pizza retrieved from the query string, if any.
  const [pizza, setPizza] =
    useState<Pizza | null>(null);

  useEffect(() => {
    // Parsing the pizza in the query string.
    const pizzaRaw = Qs.parse(searchParams.toString());

    // TODO: what now?
  }, [searchParams]);

  // Displaying information about the pizza, if any.
  return (
    <div className={'querystring-reader'}>
      <h1>PizzaReader</h1>
      {pizza !== null ? (
        <div className={'pizza-info'}>
          <div>
            <div className={'bold'}>ID</div>
            <div>{pizza.id}</div>
          </div>
          <div>
            <div className={'bold'}>Name</div>
            <div>{pizza.name}</div>
          </div>
          <div>
            <div className={'bold'}>Description</div>
            <div>{pizza.description}</div>
          </div>
          <div>
            {/* If this is a pepperoni pizza, meaning the one we expect, saying Yes. */}
            <div className={'bold'}>Pepperoni</div>
            <div>{pizza.id === pizzaToRead.id ? 'Yes' : 'No'}</div>
          </div>
        </div>
      ) : (
        'No pizza in the query string :('
      )}
    </div>
  );
};

export default PizzaReader;

A possible approach is to define a custom type guard, so we can check for the object to have the properties we expect.

// src/helpers/pizzaHelper.ts
import {Pizza} from '../models/Pizza';

// Type guard to make sure any object is a Pizza.
export const isPizza = (obj: any): obj is Pizza => {
  return 'id' in obj && 'name' in obj && 'description' in obj;
}

Let’s try using this to complete the effect in PizzaReader

import {isPizza} from '../helpers/pizzaHelper';

 …

  useEffect(() => {
    // Parsing the pizza in the query string.
    const pizzaRaw = Qs.parse(searchParams.toString());

    // If the object is a pizza, saving that in the state.
    if (isPizza(pizzaRaw)) {
      setPizza(pizzaRaw);
    }
  }, [searchParams]);

According to TypeScript, everything is fine. If we run our web app using…

npm run start

…and open the browser, we’ll see PizzaReader ready to receive its hard-earned Pizza.

Let’s press the button to deliver our marvelous baked disc of dough, and let’s see what changes.

The  result may appear satisfying at first glance, but something’s not right. The data are correct, except for the fact that PizzaReader doesn’t realize this is a pepperoni pizza. Why is that?

The answer is in the comparison we made on the ID.

<div>
            {/* If this is a pepperoni pizza, meaning the one we expect, saying Yes. */}
            <div className={'bold'}>Pepperoni</div>
            <div>{pizza.id === pizzaToRead.id ? 'Yes' : 'No'}</div>
          </div>

Here’s the issue: pizzaToRead was defined in our own code according to its interface, while pizza is retrieved from the query string. In the former, the ID is a number; in the latter, however, since a query string doesn’t have any indication about typing, every property is a string. Thus, the strict equality operator returns false; the types are different, even though TypeScript has no way to know beforehand.

Solving this situation is not as easy as it may look. Sure, for such a simple case we could resort to a plain equality operator, but what about complex scenarios? What if we have to use a specific method from the String or the Number prototype?

One might try refactoring the type guard isPizza to be more strict, so it checks the types of the properties as well as their existence, but that would cause the web app to think the object in the query string is not a Pizza at all, leaving poor PizzaReader on an empty state – and stomach. So? Do we have to create some complex parser function for every interface in our project?

No: there is a quicker and safer way, and it comes from the Yup library.

If you’re already used to React, you’ve likely heard about Yup. It’s one of the most popular libraries around when it comes to form validation, often used alongside Formik. Form validation is not the only thing Yup is good at, though: right now, we’re looking for the cast method. It’s a feature that, given a value which may or may not be an object, attempts to build a second value which respects a specific schema, just like the schema you’d use while validating a form.

Let’s install Yup and its type declarations…

npm install yup
npm install -D @types/yup

…and add a Yup schema next to the Pizza interface.

// src/models/Pizza.ts
import * as yup from 'yup';

export interface Pizza {
  id: number

  name: string

  description?: string
}

// Yup schema for a Pizza object.
export const pizzaSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required(),
  description: yup.string(),
});

Finally, we’ll add a third component, PizzaTypedReader. Its structure will be the same as PizzaReader, only the value from the query string will be parsed using the schema.

import React, {ReactElement, useEffect, useState} from 'react';
// src/components/PizzaTypedReader.tsx
import {useSearchParams} from 'react-router-dom';
import Qs from 'qs';
import {Pizza, pizzaSchema} from '../models/Pizza';

// The pizza we're expecting to find in the query string.
const pizzaToRead: Pizza = {
  id: 1,
  name: 'Pepperoni',
  description: 'So good!',
};

const PizzaTypedReader = (): ReactElement | null => {

  …

  useEffect(() => {
    // Parsing the pizza in the query string.
    const pizzaRaw = Qs.parse(searchParams.toString());

    // Trying to parse a pizza with Yup.cast.
    try {
      const newPizza = pizzaSchema.cast(pizzaRaw) as Pizza;

      // The object is a pizza.
      setPizza(newPizza);
    } catch (error) {
      // The object is *not* a pizza.
      setPizza(null);
    }
  }, [searchParams]);

  …
};

export default PizzaTypedReader;

The cast method takes the object from the query string and returns a new object respecting the given schema, but only if the input is suitable for casting according to said schema. In this case, for instance, the numeric string ‘1’ is cast to the number value 1, because the schema asserts the id property must be a number. If an incompatible value, like a non-numeric string, was provided, or a required property was missing, an error would be thrown. So, if the method is successful, we can be sure newPizza is actually a Pizza, with a little type assertion to make TypeScript happy.

Let’s add the third component next to the other ones…

// src/pages/PizzaWrapper.tsx
import React, {ReactElement} from 'react';
import PizzaWriter from '../components/PizzaWriter';
import PizzaReader from '../components/PizzaReader';
import PizzaTypedReader from '../components/PizzaTypedReader';

const PizzaWrapper = (): ReactElement | null => {
  return (
    <>
      <PizzaWriter/>
      <PizzaReader/>
      <PizzaTypedReader/>
    </>
  );
}

export default PizzaWrapper;

…and try again.

Now we’re talking! The new ID is a number, and the strict comparison returns true.

We can use Yup cast in any situation to make sure the values we get at runtime are actually typed like we expect in your code. Regardless of whether we have a scalar value, an object, or an array of objects, it doesn’t matter the complexity of the schema. This allows us to validate the structure of data retrieved from untrustworthy sources, like the localStorage , a query string or any storage that could be easily manipulated by a malicious user. Don’t rely too much on it, though: this feature can only validate the type of your data, not the actual content, so stay alert. the full project repository on Github. As for me, I think I’ll order a pizza.

Tags :