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.