View
logo
logo

Contatti

Labs Sviluppo Software 22 Agosto 2023

Parse di oggetti tipati con Yup cast

Writen by odc-admin

comments 0

React Yup Cast

Nel corso del lavoro su un progetto web in React, in un qualsiasi framework che lo utilizzi, o anche in altri progetti JavaScript, è possibile ritrovarsi nella situazione in cui bisogna recuperare dati da fonti esterne.

C’è una grande quantità e varietà di fonti dati: API web, SDK, documenti sul file system, il localStorage del browser, una query string. Spesso, ad esempio, può capitare di dover reperire dati serializzati in forma testuale che magari noi stessi avevamo salvato da qualche parte per memorizzare una scelta dell’utente.

Leggere queste informazioni, che lo si faccia manualmente con gli strumenti nativi di JavaScript o che si usi una libreria, tipicamente è piuttosto semplice: si interroga la fonte dati, si ottiene un elemento o una lista ed è tutto pronto.

…oppure no?

JavaScript, si sa, è un linguaggio dinamicamente tipato, e usare TypeScript – come facciamo noi – per aiutarci a identificare errori di tipo prima che sia troppo tardi non toglie il fatto che a runtime la tipizzazione di variabili e proprietà sia dinamica. Questo significa che, soprattutto quando si leggono dati da fonti puramente testuali, potremmo ottenere valori che non ci aspettiamo.

Tipicamente ciò avviene con campi numerici, ma può riguardare qualunque altro tipo di dato non testuale: abbiamo un valore numerico salvato da qualche parte, lo recuperiamo da una fonte dati e lo usiamo in un confronto o in qualche altra operazione aspettandoci che sia un number, per poi scoprire a runtime che in realtà si tratta di una stringa, ritrovandoci con bug subdoli e poco evidenti a prima vista. Vediamo un esempio pratico.

Immaginiamo una situazione che sarà sicuramente capitata a chiunque si trovi nello sviluppo web: stiamo creando un’applicazione React che salva in query string i dati di una deliziosa pizza, e in seguito li recupera per mostrarli all’utente.

Creiamo allora un nuovo progetto create-react-app, con TypeScript come piace a noi, e mettiamoci al lavoro.

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

Per serializzare e deserializzare gli oggetti useremo la libreria Qs, con react-router e react-router-dom per la manipolazione della query string, senza dimenticare le dichiarazioni TypeScript.

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

Innanzitutto, creiamo un semplice modello dati per la nostra pizza, con un ID e un paio di campi testuali.

// src/models/Pizza.ts
// Interfaccia per la struttura dati pizza.
export interface Pizza {
  id: number

  name: string

  description?: string
}

La nostra applicazione web avrà due componenti.

  • PizzaWriter prenderà una Pizza e la salverà nella query string.
  • PizzaReader starà in ascolto sulla query string e, un po’ come me quando aspetto il fattorino di Just Eat sulla porta, non appena ci troverà una Pizza la consumerà e ne mostrerà i dati all’utente.

I componenti si troveranno all’interno del contenitore 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;

…che sarà la root della navigazione.

// 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 è semplice: alla pressione di un pulsante, serializza un oggetto pizza con Qs e salva il risultato in query string con l’hook di 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';

// La nostra pizza, da salvare in query string.
const pizzaToWrite: Pizza = {
  id: 1,
  name: 'Margherita',
  description: 'La classica!',
};

const PizzaWriter = (): ReactElement => {
  // Metodo per modificare la query string.
  const [, setSearchParams] = useSearchParams();

  // Al click, la pizza viene salvata in query string.
  const savePizzaInQueryString = (): void => {
    setSearchParams(Qs.stringify(pizzaToWrite));
  }

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

export default PizzaWriter;

PizzaReader è dove le cose iniziano a complicarsi un po’. Di base, quel che vogliamo è stare in ascolto sulla query string, sempre con useSearchParams, per essere pronti a ricevere una Pizza e metterla nello stato. Appena arriva, mostriamo all’utente i dati della Pizza. Ci aspettiamo di ricevere una margherita, quindi controlliamo anche, in base all’ID, che la pizza sia quella che abbiamo ordinato.

Ma come facciamo a essere sicuri che l’oggetto che leggiamo sia proprio una 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';

// La pizza che ci si aspetta di ricevere dalla query string.
const pizzaToRead = {
  id: 1,
  name: 'Margherita',
  description: 'La classica!',
};

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

  // La pizza che è stata recuperata dalla query string.
  const [pizza, setPizza] =
    useState<Pizza | null>(null);

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

    // TODO: e adesso?
  }, [searchParams]);

  // I dati della pizza vengono mostrati all'utente, se presenti.
  return (
    <div className={'querystring-reader'}>
      <h1>Query string reader</h1>
      {pizza !== null ? (
        <div className={'pizza-info'}>
          <div>
            <div className={'bold'}>ID</div>
            <div>{pizza.id}</div>
          </div>
          <div>
            <div className={'bold'}>Nome</div>
            <div>{pizza.name}</div>
          </div>
          <div>
            <div className={'bold'}>Descrizione</div>
            <div>{pizza.description}</div>
          </div>
          <div>
            {/* Se la pizza è una margherita, si mostra l'informazione, in base all'ID. */}
            <div className={'bold'}>Margherita</div>
            <div>{pizza.id === pizzaToRead.id ? 'Sì' : 'No'}</div>
          </div>
        </div>
      ) : (
        'Nessuna pizza in query string :('
      )}
    </div>
  );
};

export default PizzaReader;

Una strada potrebbe essere quella di definire una type guard, per assicurarci che l’oggetto in query string abbia i campi che ci aspettiamo.

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

// Type guard per verificare che un oggetto qualsiasi sia una pizza.
export const isPizza = (obj: any): obj is Pizza => {
  return 'id' in obj && 'name' in obj && 'description' in obj;
}

Proviamo a completare la useEffect di PizzaReader così.

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

 …

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

    // Se l'oggetto è una pizza, lo si salva nello stato.
    if (isPizza(pizzaRaw)) {
      setPizza(pizzaRaw);
    }
  }, [searchParams]);

Dal punto di vista di TypeScript, è tutto a posto. Facciamo partire la nostra applicazione con…

npm run start

…e apriamo il browser, per vedere PizzaReader pronto a ricevere una Pizza.

Premiamo senza indugio il pulsante per consegnare il nostro stupendo pacchetto di amore e carboidrati, e vediamo come cambia la situazione.

A una prima occhiata è tutto a posto, ma qualcosa non va. I dati della Pizza sembrano corretti, se non per il fatto che PizzaReader non vede la Pizza come una margherita. Come mai?

La chiave è nel confronto che stiamo facendo sull’ID.

<div>
            {/* Se la pizza è una margherita, si mostra l'informazione, in base all'ID. */}
            <div className={'bold'}>Margherita</div>
            <div>{pizza.id === pizzaToRead.id ? 'Sì' : 'No'}</div>
          </div>

Il problema è che la pizzaToRead è stata definita nel nostro codice, rispettando l’interfaccia definita, mentre pizza viene recuperata dalla query string. Nel primo caso, l’ID viene correttamente impostato come un number; nel secondo, però, non avendo la query string nessuna indicazione sul tipo delle variabili, tutti i valori avranno tipo string a runtime. La strict equality che ci aspettiamo, dunque, non è rispettata: i tipi sono diversi, anche se TypeScript non può saperlo.

Risolvere questa situazione non è banale come può sembrare. Certo, per un caso così semplice potremmo usare la semplice equality, ma in situazioni più complesse? Se dovessimo usare un metodo specifico di String o di Number?

Si potrebbe pensare di rendere più stretta la type guard isPizza per controllare anche il tipo dei valori, ma questo ci porterebbe a non considerare l’oggetto in query string come una Pizza, lasciando il PizzaReader a stato e pancia vuoti. E allora? Dobbiamo costruire una complessa funzione parser per ogni interfaccia della nostra applicazione?

No: esiste un modo più semplice e sicuro, e viene dalla libreria Yup.

Se siete abituati a lavorare in React, molto probabilmente conoscerete già Yup: è una delle librerie più diffuse per la validazione dei form, spesso usata insieme a Formik. Ma validare i form non è l’unica cosa di cui è capace; per il nostro problema, in particolare, ci interessa il metodo cast. Si tratta di una funzionalità che permette, dato un valore che può essere un oggetto, di tentare di estrapolare un secondo valore che rispetta uno specifico schema, proprio come quelli usati nella validazione dei form.

Installiamo Yup e le sue dichiarazioni di tipo…

npm install yup
npm install -D @types/yup

…e, insieme all’interfaccia, creiamo anche lo schema Yup per Pizza

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

// Interfaccia per la struttura dati pizza.
export interface Pizza {
  id: number

  name: string

  description?: string
}

// Schema Yup per la struttura dati pizza.
export const pizzaSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required(),
  description: yup.string(),
});

Infine, creiamo un terzo componente, PizzaTypedReader. La sua struttura sarà identica al PizzaReader, se non per il fatto che userà lo schema per il parsing del valore in query string.

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

// La pizza che ci si aspetta di ricevere dalla query string.
const pizzaToRead = {
  id: 1,
  name: 'Margherita',
  description: 'La classica!',
};

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

  …

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

    // Si usa Schema.cast di Yup per tentare di fare il parsing dell'oggetto.
    try {
      // Richiesta la type assertion as Pizza per evitare errori di tipo.
      const newPizza = pizzaSchema.cast(pizzaRaw) as Pizza;

      // L'oggetto è una pizza.
      setPizza(newPizza);
    } catch (error) {
      // L'oggetto non è una pizza.
      setPizza(null);
    }
  }, [searchParams]);

  …
};

export default PizzaTypedReader;

Il metodo cast dello schema tenterà di restituire un oggetto che rispetta la struttura dati definita. In questo caso, il valore string ‘1’ subirà un casting nel number 1, dato che lo schema impone che il campo id sia di tipo number. Se l’input non è compatibile con lo schema, ad esempio perché id è una stringa non numerica oppure perché manca un campo non opzionale, verrà lanciato un errore. Nel momento in cui la chiamata ha successo, quindi, possiamo essere certi che newPizza sia una Pizza, con una piccola type assertion per convincere anche TypeScript della cosa.

Aggiungiamo il terzo componente insieme agli altri…

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

…e proviamo di nuovo.

Adesso sì che ci siamo! Grazie a Yup, l’ID di newPizza è un number, e la strict comparison ha successo.

Possiamo usare il metodo cast in qualunque situazione per assicurarci che i tipi a runtime siano quelli che ci aspettiamo nel codice: valori scalari, oggetti, array di oggetti, con ogni tipo di schema, non importa quanto complesso. Questo ci permette anche di validare la struttura di dati provenienti da fonti poco affidabili, ad esempio informazioni che possono essere facilmente modificate da utenti malintenzionati, come il localStorage del browser o, appunto, una query string. Occhio a non farci troppo affidamento, però: questa validazione si limita al tipo, il contenuto effettivo è tutta un’altra storia!Spero che questa lettura possa essere stata utile. Se vi va, potete dare un’occhiata alla repository del progetto. Io credo che ordinerò una pizza.

Foto di Lukas

Tags :