React con TypeScript

TypeScript ha trasformato il modo in cui scriviamo applicazioni React. Quello che una volta era un ecosistema dominato da JavaScript puro si e' spostato, in modo quasi unanime, verso un approccio tipizzato che porta benefici concreti: errori intercettati prima della compilazione, autocompletamento intelligente nell'editor, contratti espliciti tra componenti e una documentazione che vive dentro il codice stesso. Questo articolo esplora in profondita' l'integrazione tra React e TypeScript, partendo dalle basi fino ad arrivare a pattern avanzati usati in produzione.

Configurazione del progetto

Il modo piu' rapido per creare un progetto React con TypeScript e' attraverso Vite, che ha sostituito Create React App come standard de facto grazie alla velocita' di build e alla configurazione minimale.

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Vite genera automaticamente un file tsconfig.json con impostazioni ragionevoli. Vale la pena comprendere le opzioni piu' rilevanti per React.

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

L'opzione "jsx": "react-jsx" abilita la nuova trasformazione JSX introdotta in React 17, che non richiede piu' l'importazione esplicita di React in ogni file. L'opzione "strict": true e' fondamentale: attiva un insieme di controlli rigorosi che includono strictNullChecks, strictFunctionTypes e noImplicitAny. Disattivarla vanifica gran parte dei vantaggi di TypeScript.

Tipizzare i componenti funzionali

I componenti funzionali sono il cuore di React moderno. La tipizzazione delle props rappresenta il primo e piu' importante passo nell'uso di TypeScript con React.

// Definizione dell'interfaccia per le props del componente
interface UserCardProps {
  name: string;
  email: string;
  avatarUrl?: string; // prop opzionale
  role: "admin" | "editor" | "viewer"; // unione di stringhe letterali
  onSelect: (userId: string) => void;
}

// Il tipo di ritorno viene inferito automaticamente da TypeScript
function UserCard({ name, email, avatarUrl, role, onSelect }: UserCardProps) {
  // Gestore dell'evento click
  const handleClick = () => {
    onSelect(email);
  };

  return (
    <button onClick={handleClick}>
      {avatarUrl && <img src={avatarUrl} alt={name} />}
      <h3>{name}</h3>
      <p>{email}</p>
      <span>{role}</span>
    </button>
  );
}

Una nota importante: evitate il tipo React.FC (o React.FunctionComponent). Era comune nei primi anni dell'adozione di TypeScript con React, ma presenta svantaggi: aggiunge implicitamente children alle props (comportamento rimosso in React 18), rende piu' difficile la tipizzazione dei generici e non offre vantaggi reali rispetto alla semplice annotazione delle props come parametro destrutturato. La community e la stessa documentazione ufficiale di React lo sconsigliano ormai da tempo.

Props con children

Quando un componente deve accettare elementi figli, bisogna dichiararlo esplicitamente. Il tipo React.ReactNode e' il piu' flessibile: accetta stringhe, numeri, elementi JSX, frammenti, null e array di tutti questi tipi.

// Tipo per i figli del componente
interface PanelProps {
  title: string;
  children: React.ReactNode;
  collapsible?: boolean;
}

function Panel({ title, children, collapsible = false }: PanelProps) {
  // Stato per gestire l'apertura/chiusura del pannello
  const [isOpen, setIsOpen] = React.useState(true);

  return (
    <details open={isOpen}>
      <summary onClick={() => collapsible && setIsOpen(!isOpen)}>
        {title}
      </summary>
      {isOpen && children}
    </details>
  );
}

Se il componente richiede che i figli siano specificamente un singolo elemento React (non una stringa o un numero), usate React.ReactElement. Se invece i figli devono essere una funzione (render prop pattern), tipizzate la funzione esplicitamente.

// Render prop: i figli sono una funzione che riceve dati e restituisce JSX
interface DataLoaderProps<T> {
  url: string;
  children: (data: T, isLoading: boolean) => React.ReactNode;
}

Gestione degli eventi

React fornisce tipi specifici per ogni categoria di evento. L'errore piu' comune e' usare i tipi nativi del DOM invece di quelli sintetici di React.

function SearchForm() {
  // Stato per il valore dell'input
  const [query, setQuery] = React.useState("");

  // Evento di modifica dell'input
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(event.target.value);
  };

  // Evento di invio del form
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log("Ricerca:", query);
  };

  // Evento da tastiera con controllo del tasto premuto
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Escape") {
      setQuery("");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        placeholder="Cerca..."
      />
    </form>
  );
}

I tipi di evento piu' usati in React sono React.ChangeEvent, React.FormEvent, React.MouseEvent, React.KeyboardEvent, React.FocusEvent e React.DragEvent. Ognuno e' generico rispetto all'elemento HTML che genera l'evento, il che permette al compilatore di conoscere le proprieta' specifiche di event.target e event.currentTarget.

Quando passate un handler come prop, il modo migliore per ottenere il tipo corretto e' usare React.ComponentProps.

// Estrarre il tipo dell'handler direttamente dal componente HTML
type InputChangeHandler = React.ComponentProps<"input">["onChange"];

useState e tipizzazione dello stato

useState inferisce il tipo dal valore iniziale nella maggior parte dei casi. L'annotazione esplicita diventa necessaria quando il valore iniziale non rappresenta l'intero spettro di valori possibili.

// Il tipo viene inferito come string
const [name, setName] = React.useState("Mario");

// Il tipo viene inferito come number
const [count, setCount] = React.useState(0);

// Serve un'annotazione esplicita: il valore iniziale e' null
// ma in seguito conterra' un oggetto User
interface User {
  id: string;
  name: string;
  email: string;
}

const [currentUser, setCurrentUser] = React.useState<User | null>(null);

// Senza annotazione, TypeScript inferirebbe "never[]"
// e impedirebbe di aggiungere elementi
const [items, setItems] = React.useState<string[]>([]);

// Stato con unione discriminata per gestire il caricamento
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

const [request, setRequest] = React.useState<RequestState<User[]>>({
  status: "idle",
});

L'ultimo esempio mostra un pattern particolarmente potente: le unioni discriminate. Usare un campo status come discriminante permette a TypeScript di restringere il tipo nelle varie diramazioni di un if o switch, garantendo l'accesso sicuro a data solo quando lo stato e' "success" e a error solo quando e' "error".

useRef e riferimenti al DOM

useRef ha due utilizzi distinti in React, ognuno con una tipizzazione diversa. La distinzione dipende dal valore iniziale passato.

function VideoPlayer() {
  // Riferimento a un elemento DOM: il tipo iniziale include null
  // e il risultato e' un RefObject (sola lettura su .current)
  const videoRef = React.useRef<HTMLVideoElement>(null);

  // Riferimento mutabile per valori persistenti tra i render
  // Passando un valore iniziale compatibile col tipo generico,
  // si ottiene un MutableRefObject
  const frameIdRef = React.useRef<number>(0);

  const play = () => {
    // .current puo' essere null: il controllo e' obbligatorio
    if (videoRef.current) {
      videoRef.current.play();
    }
  };

  const startAnimation = () => {
    // .current e' sempre number, nessun controllo necessario
    frameIdRef.current = requestAnimationFrame(animate);
  };

  const animate = () => {
    // Logica di animazione
    frameIdRef.current = requestAnimationFrame(animate);
  };

  React.useEffect(() => {
    return () => cancelAnimationFrame(frameIdRef.current);
  }, []);

  return <video ref={videoRef} src="/clip.mp4" />;
}

La regola pratica: se il ref verra' passato all'attributo ref di un elemento JSX, usate useRef<HTMLElement>(null). Se il ref serve come variabile d'istanza per valori che persistono tra i render senza causare re-render, passate un valore iniziale del tipo corretto.

useReducer con unioni discriminate

useReducer esprime il suo massimo potenziale con TypeScript quando si combinano unioni discriminate per le azioni e stati tipizzati rigorosamente.

// Definizione dello stato
interface TodoState {
  todos: Array<{ id: string; text: string; completed: boolean }>;
  filter: "all" | "active" | "completed";
}

// Unione discriminata per le azioni: ogni azione ha un tipo
// e un payload specifico
type TodoAction =
  | { type: "ADD_TODO"; payload: { text: string } }
  | { type: "TOGGLE_TODO"; payload: { id: string } }
  | { type: "DELETE_TODO"; payload: { id: string } }
  | { type: "SET_FILTER"; payload: { filter: TodoState["filter"] } };

// Il reducer e' completamente type-safe: TypeScript verifica
// che ogni caso dello switch gestisca il payload corretto
function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "ADD_TODO":
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: crypto.randomUUID(),
            text: action.payload.text, // TypeScript sa che qui c'e' "text"
            completed: false,
          },
        ],
      };

    case "TOGGLE_TODO":
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };

    case "DELETE_TODO":
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id),
      };

    case "SET_FILTER":
      return { ...state, filter: action.payload.filter };
  }
}

function TodoApp() {
  const [state, dispatch] = React.useReducer(todoReducer, {
    todos: [],
    filter: "all",
  });

  // TypeScript impedisce di inviare azioni malformate
  // dispatch({ type: "ADD_TODO" }) // Errore: manca payload
  // dispatch({ type: "UNKNOWN" }) // Errore: tipo non valido

  const addTodo = (text: string) => {
    dispatch({ type: "ADD_TODO", payload: { text } });
  };

  return <p>{state.todos.length} elementi</p>;
}

useContext con tipizzazione sicura

Il Context API di React richiede attenzione nella tipizzazione per evitare l'uso di valori undefined non gestiti. Il pattern piu' robusto combina un contesto con un hook personalizzato che garantisce l'esistenza del valore.

// Definizione del tipo per il contesto di autenticazione
interface AuthContextValue {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

// Creazione del contesto con valore iniziale undefined
// per forzare l'uso del Provider
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);

// Hook personalizzato che solleva un errore se il contesto
// viene usato fuori dal Provider
function useAuth(): AuthContextValue {
  const context = React.useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth deve essere usato dentro un AuthProvider");
  }
  return context;
}

// Provider che incapsula la logica di autenticazione
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = React.useState<User | null>(null);

  const login = async (email: string, password: string) => {
    const response = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });
    const data = await response.json();
    setUser(data.user);
  };

  const logout = () => {
    setUser(null);
  };

  // Il valore viene calcolato a ogni render: in produzione
  // andrebbe memorizzato con useMemo
  const value: AuthContextValue = {
    user,
    login,
    logout,
    isAuthenticated: user !== null,
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// Utilizzo nei componenti: nessun controllo null necessario
function UserProfile() {
  const { user, logout, isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <p>Accedi per vedere il profilo</p>;
  }

  return (
    <p>
      Benvenuto, {user?.name}
      <button onClick={logout}>Esci</button>
    </p>
  );
}

L'alternativa di passare un valore di default a createContext (come un oggetto con funzioni vuote) potrebbe sembrare piu' comoda, ma nasconde errori reali. Con il pattern sopra, un componente che tenta di usare useAuth fuori dall'AuthProvider riceve immediatamente un errore esplicito invece di comportarsi in modo silenziosamente sbagliato.

Componenti generici

I generici permettono di creare componenti riutilizzabili che mantengono la sicurezza dei tipi rispetto ai dati che manipolano. Questo pattern e' particolarmente utile per liste, tabelle, selettori e qualsiasi componente che opera su dati la cui struttura e' definita dal consumatore.

// Vincolo: T deve avere almeno un campo "id"
interface ListProps<T extends { id: string | number }> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  onItemClick?: (item: T) => void;
  emptyMessage?: string;
}

// Componente generico: la sintassi <T extends ...> prima dei parametri
function List<T extends { id: string | number }>({
  items,
  renderItem,
  onItemClick,
  emptyMessage = "Nessun elemento",
}: ListProps<T>) {
  if (items.length === 0) {
    return <p>{emptyMessage}</p>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={item.id} onClick={() => onItemClick?.(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Utilizzo: TypeScript inferisce T dalla prop "items"
interface Product {
  id: number;
  name: string;
  price: number;
}

function ProductCatalog() {
  const products: Product[] = [
    { id: 1, name: "Tastiera", price: 79 },
    { id: 2, name: "Mouse", price: 49 },
  ];

  return (
    <List
      items={products}
      renderItem={(product) => (
        // "product" e' tipizzato come Product automaticamente
        <span>{product.name} - {product.price} EUR</span>
      )}
      onItemClick={(product) => {
        // Anche qui "product" e' Product
        console.log("Selezionato:", product.name);
      }}
    />
  );
}

Il generico viene inferito automaticamente da TypeScript quando si passa la prop items. Non serve scrivere <List<Product> items={...} /> esplicitamente, anche se rimane possibile farlo in casi ambigui.

Hooks personalizzati

Gli hooks personalizzati sono funzioni che incapsulano logica riutilizzabile. TypeScript rende esplicito il contratto di ogni hook: cosa accetta e cosa restituisce.

// Hook generico per le chiamate API con gestione completa
// di caricamento, errori e ricaricamento
function useFetch<T>(url: string) {
  const [data, setData] = React.useState<T | null>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [isLoading, setIsLoading] = React.useState(false);

  const fetchData = React.useCallback(async () => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(`Errore HTTP: ${response.status}`);
      }

      const result: T = await response.json();
      setData(result);
    } catch (err) {
      // Gestione sicura dell'errore: "err" e' di tipo unknown
      const message = err instanceof Error ? err.message : "Errore sconosciuto";
      setError(message);
    } finally {
      setIsLoading(false);
    }
  }, [url]);

  React.useEffect(() => {
    fetchData();
  }, [fetchData]);

  // Restituzione di un oggetto con nome per chiarezza
  return { data, error, isLoading, refetch: fetchData };
}

// Hook per la gestione del localStorage con serializzazione
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // Inizializzazione pigra: legge dal localStorage solo al primo render
  const [storedValue, setStoredValue] = React.useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item !== null ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Sovrascrittura del setter per sincronizzare con localStorage
  const setValue = (value: T | ((prev: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  };

  return [storedValue, setValue];
}

// Utilizzo degli hook personalizzati
interface Post {
  id: number;
  title: string;
  body: string;
}

function BlogPage() {
  // TypeScript inferisce che "data" e' Post[] | null
  const { data: posts, isLoading, error } = useFetch<Post[]>(
    "https://jsonplaceholder.typicode.com/posts"
  );

  // TypeScript sa che "theme" e' di tipo string
  const [theme, setTheme] = useLocalStorage<string>("theme", "light");

  if (isLoading) return <p>Caricamento...</p>;
  if (error) return <p>Errore: {error}</p>;

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Notate come useFetch restituisce un oggetto con chiavi nominate, mentre useLocalStorage restituisce una tupla (array di lunghezza fissa con tipi posizionali). La scelta tra le due forme dipende dal caso d'uso: le tuple sono idiomatiche quando il consumatore vorra' rinominare i valori (come useState), gli oggetti quando i nomi sono gia' significativi.

Pattern di composizione avanzati

Componenti polimorfi

Un componente polimorfo puo' renderizzare un elemento HTML diverso in base a una prop, mantenendo la tipizzazione corretta degli attributi.

// Tipo che accetta un tag HTML o un componente React
type AsProp<C extends React.ElementType> = {
  as?: C;
};

// Combina le props del componente con quelle dell'elemento scelto,
// omettendo eventuali conflitti di nome
type PolymorphicProps<C extends React.ElementType, Props = object> = Props &
  AsProp<C> &
  Omit<React.ComponentPropsWithoutRef<C>, keyof Props | "as">;

// Componente polimorfo per il testo
interface TextOwnProps {
  size?: "small" | "medium" | "large";
  weight?: "normal" | "bold";
}

type TextProps<C extends React.ElementType = "span"> = PolymorphicProps<
  C,
  TextOwnProps
>;

function Text<C extends React.ElementType = "span">({
  as,
  size = "medium",
  weight = "normal",
  ...rest
}: TextProps<C>) {
  const Component = as || "span";
  return <Component {...rest} />;
}

// Uso: TypeScript conosce gli attributi validi per ogni tag
function Example() {
  return (
    <>
      {/* Come span: accetta attributi di span */}
      <Text size="large">Testo standard</Text>

      {/* Come link: accetta href e gli altri attributi di <a> */}
      <Text as="a" href="/pagina" size="small">
        Un collegamento
      </Text>

      {/* Come etichetta: accetta htmlFor */}
      <Text as="label" htmlFor="campo">
        Etichetta
      </Text>
    </>
  );
}

Pattern Compound Component

I compound component sono un gruppo di componenti che lavorano insieme condividendo uno stato implicito attraverso il Context. Questo pattern e' alla base di librerie come Radix UI e Headless UI.

// Contesto condiviso tra i sotto-componenti
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
}

const TabsContext = React.createContext<TabsContextValue | undefined>(
  undefined
);

function useTabsContext(): TabsContextValue {
  const context = React.useContext(TabsContext);
  if (!context) {
    throw new Error(
      "I sotto-componenti di Tabs devono essere usati dentro <Tabs>"
    );
  }
  return context;
}

// Componente radice che gestisce lo stato
interface TabsRootProps {
  defaultTab: string;
  children: React.ReactNode;
}

function TabsRoot({ defaultTab, children }: TabsRootProps) {
  const [activeTab, setActiveTab] = React.useState(defaultTab);

  const value = React.useMemo(
    () => ({ activeTab, setActiveTab }),
    [activeTab]
  );

  return (
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}

// Sotto-componente per la singola linguetta
interface TabTriggerProps {
  id: string;
  children: React.ReactNode;
}

function TabTrigger({ id, children }: TabTriggerProps) {
  const { activeTab, setActiveTab } = useTabsContext();

  return (
    <button
      role="tab"
      aria-selected={activeTab === id}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
}

// Sotto-componente per il contenuto
interface TabContentProps {
  id: string;
  children: React.ReactNode;
}

function TabContent({ id, children }: TabContentProps) {
  const { activeTab } = useTabsContext();

  if (activeTab !== id) return null;

  return <p role="tabpanel">{children}</p>;
}

// Assemblaggio con namespace: Tabs.Root, Tabs.Trigger, Tabs.Content
const Tabs = {
  Root: TabsRoot,
  Trigger: TabTrigger,
  Content: TabContent,
};

// Utilizzo pulito e leggibile
function SettingsPage() {
  return (
    <Tabs.Root defaultTab="general">
      <Tabs.Trigger id="general">Generali</Tabs.Trigger>
      <Tabs.Trigger id="security">Sicurezza</Tabs.Trigger>
      <Tabs.Trigger id="notifications">Notifiche</Tabs.Trigger>

      <Tabs.Content id="general">Impostazioni generali</Tabs.Content>
      <Tabs.Content id="security">Impostazioni di sicurezza</Tabs.Content>
      <Tabs.Content id="notifications">Preferenze notifiche</Tabs.Content>
    </Tabs.Root>
  );
}

Tipizzazione delle API e validazione a runtime

TypeScript opera solo a tempo di compilazione. I dati che arrivano da un'API esterna sono unknown a runtime: la tipizzazione di fetch e' una promessa, non una garanzia. Per colmare questo divario si usano librerie di validazione come Zod, che generano sia la validazione runtime sia il tipo TypeScript dalla stessa definizione.

import { z } from "zod";

// Lo schema Zod definisce struttura e regole di validazione
const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.string().datetime(),
  preferences: z
    .object({
      theme: z.enum(["light", "dark"]).default("light"),
      language: z.string().default("it"),
    })
    .optional(),
});

// Il tipo TypeScript viene estratto dallo schema: nessuna duplicazione
type User = z.infer<typeof UserSchema>;

// Schema per la risposta paginata dell'API
const PaginatedResponseSchema = z.object({
  data: z.array(UserSchema),
  total: z.number(),
  page: z.number(),
  perPage: z.number(),
});

type PaginatedResponse = z.infer<typeof PaginatedResponseSchema>;

// Funzione di fetch con validazione: i dati in uscita
// sono garantiti conformi allo schema
async function fetchUsers(page: number): Promise<PaginatedResponse> {
  const response = await fetch(`/api/users?page=${page}`);

  if (!response.ok) {
    throw new Error(`Errore HTTP: ${response.status}`);
  }

  const json: unknown = await response.json();

  // Validazione a runtime: lancia un errore dettagliato
  // se i dati non corrispondono allo schema
  const validated = PaginatedResponseSchema.parse(json);

  return validated;
}

Il vantaggio di Zod e' che elimina la duplicazione tra lo schema di validazione e il tipo TypeScript. Quando la struttura dell'API cambia, basta aggiornare lo schema: il tipo si aggiorna automaticamente e il compilatore segnala tutti i punti del codice che devono adattarsi.

Tipi di utilita' per React

TypeScript e i tipi forniti da @types/react offrono strumenti potenti per manipolare e comporre tipi in modi che riducono la duplicazione e aumentano la flessibilita'.

// Estrarre le props di un componente esistente
type ButtonProps = React.ComponentProps<typeof Button>;

// Estrarre le props di un elemento HTML nativo
type InputProps = React.ComponentProps<"input">;

// Estendere le props di un elemento nativo con le proprie
interface SearchInputProps extends React.ComponentProps<"input"> {
  onSearch: (query: string) => void;
  debounceMs?: number;
}

// Omit per escludere props che si vogliono ridefinire
interface CustomSelectProps
  extends Omit<React.ComponentProps<"select">, "onChange" | "value"> {
  value: string | string[];
  onChange: (value: string | string[]) => void;
}

// Pick per estrarre solo le props necessarie
type PositionProps = Pick<React.CSSProperties, "position" | "top" | "left">;

// Record per oggetti con chiavi dinamiche ma valori tipizzati
type FormErrors = Record<string, string | undefined>;

// Partial per rendere tutte le proprieta' opzionali (utile per gli update)
type UserUpdate = Partial<Omit<User, "id" | "createdAt">>;

// Required per rendere obbligatorie proprieta' opzionali
type StrictConfig = Required<AppConfig>;

L'uso strategico di Omit, Pick, Partial e Required permette di derivare tipi correlati da un'unica fonte di verita', evitando la proliferazione di interfacce quasi identiche che divergono silenziosamente nel tempo.

Gestione dei tipi per le librerie esterne

Non tutte le librerie JavaScript includono le definizioni TypeScript. Quando mancano, il compilatore segnala un errore. Le soluzioni sono tre, in ordine di preferenza.

La prima e' installare i tipi dalla community tramite DefinitelyTyped. Questo archivio contiene definizioni per migliaia di pacchetti.

# Installare i tipi per una libreria che non li include
npm install -D @types/lodash

La seconda, quando i tipi della community non esistono o sono inadeguati, e' creare un file di dichiarazione nel progetto.

// src/types/untyped-library.d.ts
// Dichiarazione del modulo con i tipi necessari
declare module "untyped-library" {
  export interface PluginOptions {
    debug?: boolean;
    locale?: string;
  }

  export function initialize(options?: PluginOptions): void;
  export function process(input: string): Promise<string>;
}

La terza, come ultima risorsa temporanea, e' dichiarare il modulo senza tipi specifici. Questo disabilita completamente il type-checking per quella libreria e andrebbe usato solo come soluzione provvisoria.

// src/types/fallback.d.ts
// Dichiarazione di fallback: tutto il modulo e' tipizzato come "any"
declare module "legacy-library";

Testing con TypeScript

TypeScript si integra con le librerie di testing React per fornire autocompletamento e verifica dei tipi anche nei test. Vitest e' la scelta naturale per progetti basati su Vite.

import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

// Funzione helper per creare props di default nei test
function createMockUserCardProps(
  overrides?: Partial<UserCardProps>
): UserCardProps {
  return {
    name: "Mario Rossi",
    email: "mario@esempio.it",
    role: "editor",
    onSelect: vi.fn(),
    ...overrides,
  };
}

describe("UserCard", () => {
  it("mostra il nome e l'email dell'utente", () => {
    const props = createMockUserCardProps();
    render(<UserCard {...props} />);

    expect(screen.getByText("Mario Rossi")).toBeInTheDocument();
    expect(screen.getByText("mario@esempio.it")).toBeInTheDocument();
  });

  it("chiama onSelect con l'email al click", () => {
    // La funzione mock e' tipizzata: vi.fn() conosce la firma
    const onSelect = vi.fn<[string], void>();
    const props = createMockUserCardProps({ onSelect });

    render(<UserCard {...props} />);
    fireEvent.click(screen.getByRole("button"));

    expect(onSelect).toHaveBeenCalledWith("mario@esempio.it");
  });

  it("non mostra l'avatar se avatarUrl non e' fornito", () => {
    const props = createMockUserCardProps({ avatarUrl: undefined });
    render(<UserCard {...props} />);

    expect(screen.queryByRole("img")).not.toBeInTheDocument();
  });
});

// Test per hook personalizzati con renderHook
import { renderHook, act } from "@testing-library/react";

describe("useLocalStorage", () => {
  it("restituisce il valore iniziale quando localStorage e' vuoto", () => {
    const { result } = renderHook(() =>
      useLocalStorage("test-key", "default")
    );

    expect(result.current[0]).toBe("default");
  });

  it("aggiorna il valore e sincronizza con localStorage", () => {
    const { result } = renderHook(() =>
      useLocalStorage("test-key", "default")
    );

    // act() e' necessario per avvolgere le operazioni che
    // modificano lo stato del componente
    act(() => {
      result.current[1]("nuovo valore");
    });

    expect(result.current[0]).toBe("nuovo valore");
    expect(localStorage.getItem("test-key")).toBe('"nuovo valore"');
  });
});

Il pattern createMockProps con Partial e' particolarmente efficace: ogni test specifica solo le props rilevanti per quel caso, rendendo chiaro cosa viene testato. Il tipo Partial garantisce che le props sovrascritte siano compatibili con l'interfaccia originale.

Errori comuni e come evitarli

Alcuni errori ricorrono con frequenza nei progetti React con TypeScript. Riconoscerli e' il primo passo per scrivere codice piu' robusto.

Il primo errore e' usare any per aggirare un errore del compilatore. Ogni any disabilita tutti i controlli a valle, creando una falla che si propaga silenziosamente. Se il tipo corretto non e' ovvio, unknown e' quasi sempre l'alternativa giusta: obbliga a un controllo esplicito prima dell'uso.

// Da evitare: any disattiva il type-checking
function processResponse(data: any) {
  return data.results.map((item: any) => item.name);
}

// Meglio: unknown obbliga a verificare la struttura
function processResponse(data: unknown) {
  // Validazione con Zod o controllo manuale
  if (
    typeof data === "object" &&
    data !== null &&
    "results" in data &&
    Array.isArray((data as { results: unknown[] }).results)
  ) {
    // Ora TypeScript sa che "results" esiste
  }
}

Il secondo errore comune e' la type assertion non sicura con as. Ogni as e' una promessa al compilatore che non viene verificata a runtime.

// Pericoloso: se l'API cambia, il crash avverra' a runtime
const user = (await response.json()) as User;

// Sicuro: la validazione avviene a runtime
const user = UserSchema.parse(await response.json());

Il terzo errore e' definire i tipi in modo troppo permissivo. Un tipo come string quando il valore puo' essere solo "loading" | "error" | "success" perde informazione preziosa che TypeScript potrebbe usare per guidare il flusso del codice.

// Troppo generico: qualsiasi stringa e' accettata
interface Config {
  theme: string;
  size: string;
}

// Preciso: solo i valori validi sono accettati
interface Config {
  theme: "light" | "dark" | "system";
  size: "small" | "medium" | "large";
}

Conclusione

L'adozione di TypeScript in un progetto React non e' un semplice cambio di estensione dei file. E' un cambio di mentalita': i tipi diventano la documentazione vivente dell'applicazione, i contratti tra componenti sono espliciti e verificabili, e intere categorie di bug vengono eliminate prima che il codice raggiunga il browser. Il costo iniziale della tipizzazione viene ripagato rapidamente dalla riduzione del tempo speso in debug, dalla facilita' di refactoring e dalla fiducia con cui si possono modificare parti del codice sapendo che il compilatore segnalera' ogni inconsistenza.

I pattern presentati in questo articolo coprono la stragrande maggioranza dei casi d'uso quotidiani. La chiave per un uso efficace di TypeScript con React e' la gradualita': partire dalla tipizzazione delle props e degli eventi, poi estendere ai generici e ai pattern di composizione man mano che la complessita' dell'applicazione cresce. TypeScript e' uno strumento al servizio dello sviluppatore, non un ostacolo da aggirare con any.