A 09-01, en construir la piràmide de proves, es va col·locar a la seva base l'anàlisi estàtica i es va dir que era «el nivell més barat»: comprovacions que s'executen sense engegar l'aplicació, sense muntar components i sense esperar res. Allà es va cobrir amb el linter i es va anunciar que l'altra meitat —els tipus— es veuria en aquesta lliçó. I el mòdul 10 ha reforçat l'argument sense anomenar-lo: a 10-03 has vist contractes per tot arreu —quines props poden travessar una frontera, quina forma té l'objecte que retorna una acció de servidor, quins camps porta una Bicicleta— i tots ells són avui implícits: viuen al cap de qui va escriure el component i només es descobreixen quan alguna cosa falla en execució. Aquesta lliçó els fa explícits i els comprova mentre escrius. Posaràs en marxa TypeScript sobre el projecte Vite de CicloUrbano de forma incremental, modelaràs el domini en un únic fitxer de tipus, i tiparàs components, hooks, esdeveniments, Redux Toolkit, TanStack Query i les respostes de l'API. L'objectiu no és aprendre TypeScript sencer, sinó el subconjunt que s'usa cada dia a React i per què.
Contingut
- L'anàlisi estàtica com a capa més barata
- Què aporta TypeScript i què costa
- Posada en marxa sobre el projecte Vite
tsconfig.json: les opcions que importen- Estratègia de migració incremental
- Fonaments que s'usen a diari
interfaceenfront detypeunknown,any, asercions i guardes de tipus- Modelar el domini:
src/tipus/domini.ts - Tipar components
- Components genèrics i per què ja no s'usa
React.FC - Tipar hooks
useReducer: on TypeScript brillauseContexti hooks propis- Tipar esdeveniments
- Redux Toolkit i TanStack Query
- Dades externes: validar al límit amb Zod
- TypeScript i proves: què detecta cadascun
- Com llegir un missatge d'error llarg
- L'anàlisi estàtica com a capa més barata
Recupera la piràmide de 09-01 i fixa't en la seva base:
| Nivell | Cost d'execució | Què detecta | Quan avisa |
|---|---|---|---|
| Anàlisi estàtica | Mil·lisegons, a l'editor | Errors de forma: noms, tipus, hooks mal utilitzats | Mentre escrius |
| Unitàries | Mil·lisegons | Lògica de funcions pures | En executar npm test |
| Integració | Dècimes de segon | Comportament de components | En executar npm test |
| Extrem a extrem | Segons | El sistema complet | A la integració contínua |
La propietat que fa especial l'anàlisi estàtica és quan avisa. Una prova et diu que alguna cosa està malament després d'escriure-la, executar-la i esperar. El tipatge t'ho diu abans de desar el fitxer, amb el cursor encara a la línia de l'error. Aquesta diferència de segons és enorme a la pràctica, perquè el context encara és al teu cap.
Un exemple real de CicloUrbano. Aquest canvi passaria totes les proves unitàries de validarReserva i tot i així trencaria l'aplicació:
// Algú reanomena el camp al model de dades.
// Abans: { id, model, tipus, estat, estacioId, preuHora }
// Ara: { id, model, tipus, estat, estacioId, preuPerHora }Sense tipus, bicicleta.preuHora retorna undefined als dotze llocs on s'usa, i la interfície mostra «NaN €/h». Ho descobreixes quan algú obre aquesta pantalla. Amb tipus, l'editor marca els dotze llocs en el moment de reanomenar el camp, i tsc falla la construcció abans de desplegar.
- Què aporta TypeScript i què costa
Siguem concrets en les dues direccions.
El que aporta:
| Avantatge | Exemple a CicloUrbano |
|---|---|
| Errors a l'editor | <TargetaBicicleta bici={...} /> quan la prop es diu bicicleta |
| Contractes explícits | En obrir TargetaBicicleta.tsx veus quines props accepta sense llegir el cos |
| Autocompletat real | bicicleta. ofereix els sis camps, no una llista buida |
| Refactoritzacions segures | Reanomenar preuHora actualitza tots els usos i marca els que no encaixen |
| Documentació viva | El tipus no es desactualitza com un comentari |
| Estretament exhaustiu | Un switch sobre estat avisa si afegeixes 'reservada' i oblides un cas |
El que costa:
| Cost | Realitat |
|---|---|
| Configuració inicial | Una tarda en un projecte existent |
| Corba d'aprenentatge | Real: genèrics, unions i utilitats porten setmanes |
| Tipus de tercers | Gairebé tot l'ecosistema els porta; alguna biblioteca antiga obliga a escriure'ls |
| Verbositat | Es compensa amb la inferència: la major part no s'escriu |
| Temps de compilació | Vite no comprova tipus en servir; tsc --noEmit és un pas a part |
Una precisió important sobre aquesta última fila, perquè sorprèn: Vite no verifica els tipus. Elimina les anotacions amb esbuild i continua. La comprovació real la fan el teu editor i la comanda tsc --noEmit, que cal executar a la integració contínua. Si no ho fas, TypeScript es converteix en decoració.
I el matís honest: TypeScript no substitueix les proves. Garanteix que preuHora és un número, no que el càlcul del preu sigui correcte. Són capes diferents i complementàries; l'apartat 18 ho detalla.
- Posada en marxa sobre el projecte Vite
Sobre el CicloUrbano existent, sense tocar res més:
Què és cada paquet:
typescript: el compilador i el servei de llenguatge que usa el teu editor.@types/reacti@types/react-dom: les definicions de tipus de React. React es distribueix sense tipus inclosos, així que van a part. Han de correspondre a React 19.
Afegeix la comanda de verificació al package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"tipus": "tsc --noEmit",
"test": "vitest",
"lint": "eslint ."
}
}tsc --noEmit significa «comprova els tipus però no generis JavaScript»: de compilar ja se n'encarrega Vite. Encadenar-lo abans de vite build converteix un error de tipus en un fallo de construcció, exactament com el linter a 09-01.
tsconfig.json: les opcions que importen
tsconfig.json: les opcions que importenA l'arrel del projecte:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}Les que cal entendre de veritat:
strict: true — activa un grup de comprovacions. La més important és strictNullChecks: sense ella, null i undefined són assignables a qualsevol tipus i TypeScript perd la meitat del seu valor.
// Amb strict activat:
function buscarBicicleta(id: string): Bicicleta | undefined { /* ... */ }
const bici = buscarBicicleta('bici-002');
console.log(bici.model);
// ~~~~ Error: 'bici' és possiblement 'undefined'
// Obligat a comprovar-ho:
if (bici) console.log(bici.model); // ✅
console.log(bici?.model); // ✅Aquest error és un TypeError: Cannot read properties of undefined que no arribarà a producció. Començar sense strict és la decisió que més es lamenta després.
jsx: "react-jsx" — la transformació moderna. Permet escriure JSX sense importar React a cada fitxer. Amb "react" (l'antiga) l'import React from 'react' torna a ser obligatori.
moduleResolution: "bundler" — li diu a TypeScript que resolgui els mòduls com ho fa Vite: sense exigir l'extensió .js a les importacions i respectant el camp exports dels paquets. És el correcte en un projecte amb empaquetador.
allowJs: true — la clau de la migració incremental. Permet que .js/.jsx i .ts/.tsx convisquin i s'importin entre si. Amb checkJs: false, els fitxers JavaScript no es comproven: es migra un a un, sense big bang.
noUncheckedIndexedAccess: true — molt recomanable i poc coneguda. Fa que accedir per índex retorni T | undefined, que és la veritat:
const bicicletes: Bicicleta[] = [];
const primera = bicicletes[0];
// Sense l'opció: Bicicleta ← mentida, l'array pot estar buit
// Amb l'opció: Bicicleta | undefined ← veritat
console.log(primera.model); // Error amb l'opció activada
console.log(primera?.model); // ✅isolatedModules: true — obligatori amb Vite. Obliga que cada fitxer es pugui transpilar per separat, cosa que a canvi exigeix export type / import type per als tipus purs.
skipLibCheck: true — no comprova els .d.ts de les dependències. Estalvia molt temps i evita errors en codi que no controles.
I el fitxer de tipus de l'entorn de Vite, a src/vite-env.d.ts:
Sense ell, import.meta.env.VITE_URL_API no està tipat.
- Estratègia de migració incremental
La regla és una: de les fulles cap a l'arrel. Es comença pel codi sense dependències i es puja.
flowchart TB
A["1. Tipus del domini<br/>src/tipus/domini.ts"] --> B["2. Utilitats pures<br/>validarReserva, classes, disponibilitat"]
B --> C["3. Hooks propis<br/>useAlternar, useDebounce, useMagatzemLocal"]
C --> D["4. Components fulla<br/>EtiquetaEstat, TargetaBicicleta"]
D --> E["5. Components compostos<br/>LlistaBicicletes, PanellReserva"]
E --> F["6. Estat global<br/>magatzem, slices, consultes"]
F --> G["7. Pàgines i rutes"]
Per què aquest ordre: un component tipat que importa una utilitat sense tipar rep any i el tipatge no serveix de res. Si la utilitat ja està tipada, el component hereta informació útil des del primer moment.
El procediment per fitxer, mecànic:
- Reanomenar:
.js→.ts,.jsx→.tsx. Un fitxer amb JSX ha de ser.tsx, sense excepció. - Executar
npm run tipusi llegir els errors. - Anotar el mínim: paràmetres de funcions i props de components. Els retorns gairebé sempre s'infereixen.
- Actualitzar les importacions als fitxers que l'usen (amb
moduleResolution: "bundler"normalment no cal tocar res). - Executar les proves del mòdul 9: són la xarxa que fa segura la migració.
Tres consells de camp:
- Migra en branques petites. Un fitxer o un grup petit per canvi. Una migració de 80 fitxers de cop és irrevisable.
- Prohibeix
anydes del primer dia, amb la regla@typescript-eslint/no-explicit-any. Unanyno és un tipus: és un forat que anul·la les comprovacions de tot el que toca. - Si t'encalles, usa
unknowni estreny després. És la sortida honesta;anyés la deshonesta.
- Fonaments que s'usen a diari
El subconjunt de TypeScript que apareix en un projecte React real és sorprenentment petit.
Primitius i inferència. Gairebé mai cal anotar una variable:
const model = 'Urbana Clàssica'; // string, inferit
const preu = 2.5; // number
const disponible = true; // boolean
// Anota només quan la inferència no basta:
let estacioSeleccionada: string | null = null;Unions de literals, l'eina més rendible del domini:
type EstatBicicleta = 'disponible' | 'alquilada' | 'mantenimiento';
let estat: EstatBicicleta = 'disponible';
estat = 'averiada';
// ~~~~~~~~~~ Error: no és assignable a EstatBicicletaCompara amb estat: string, que accepta 'DISPONIBLE', 'disponible ' amb espai i 'plàtan'. La unió de literals converteix un error de dades en un error de compilació, i a més fa que l'editor autocompleti els tres valors vàlids.
Arrays i opcionals:
const models: string[] = ['Urbana Clàssica', 'Elèctrica Pro'];
const preus: Array<number> = [2.5, 4.0, 5.5]; // sintaxi equivalent
interface Filtres {
tipus?: TipusBicicleta; // pot no estar-hi: TipusBicicleta | undefined
nomesDisponibles: boolean;
}Funcions:
// Paràmetres anotats, retorn inferit com a number.
function calcularPreu(preuHora: number, hores: number) {
return preuHora * hores;
}
// Amb valor per defecte i retorn explícit quan aporta claredat.
function formatarPreu(preu: number, moneda: string = '€'): string {
return `${preu.toFixed(2)} ${moneda}`;
}
// Tipus d'una funció, útil per a props.
type AlSeleccionar = (bicicletaId: string) => void;Genèrics bàsics. Un genèric és un tipus que es decideix al punt d'ús:
// Sense genèric: es perd el tipus de l'element.
function primerMalament(llista: unknown[]): unknown { return llista[0]; }
// Amb genèric: es conserva.
function primer<T>(llista: T[]): T | undefined {
return llista[0];
}
const b = primer(bicicletes); // Bicicleta | undefined
const n = primer([1, 2, 3]); // number | undefinedT no és màgia: és un paràmetre del tipus, igual que llista és un paràmetre del valor.
interface enfront de type
interface enfront de typeLes dues declaren la forma d'un objecte i en el 90 % dels casos són intercanviables.
interface Bicicleta {
id: string;
model: string;
preuHora: number;
}
type BicicletaAlt = {
id: string;
model: string;
preuHora: number;
};Diferències reals:
interface |
type |
|
|---|---|---|
| Objectes | Sí | Sí |
Unions ('a' | 'b') |
No | Sí |
| Tuples, primitius, funcions | Limitat | Sí |
| Estendre | extends |
Intersecció & |
| Fusió de declaracions | Sí | No |
| Missatges d'error | Solen ser més llegibles | A vegades s'expandeixen molt |
La fusió de declaracions és el punt que decideix: dues interface amb el mateix nom es combinen en silenci. És imprescindible per ampliar tipus de biblioteques, i perillós per a tipus propis, perquè un nom repetit no dona error.
Convenció pràctica d'aquest curs:
interfaceper a la forma de les entitats del domini, que poden créixer.typeper a unions, àlies, props de components i tota la resta.
unknown, any, asercions i guardes de tipus
unknown, any, asercions i guardes de tipusany desactiva TypeScript en tot el que toca:
const dades: any = await resposta.json();
dades.bicicletes.map((b) => b.preuHora * 2); // sense comprovar res
dades.aixo.no.existeix.tampoc; // tampoc dona errorunknown és el valor desconegut honest: no es pot usar sense comprovar-lo abans.
const dades: unknown = await resposta.json();
dades.bicicletes;
// ~~~~~~~~~~ Error: 'dades' és de tipus 'unknown'
if (typeof dades === 'object' && dades !== null && 'bicicletes' in dades) {
// aquí ja es pot treballar amb això
}any |
unknown |
|
|---|---|---|
| Se li pot assignar qualsevol cosa | Sí | Sí |
| Es pot usar sense comprovar | Sí (perillós) | No |
| Propaga la desactivació de comprovacions | Sí | No |
| Quan usar-lo | Gairebé mai | Dades externes, catch, migracions |
Les asercions (as) són una promesa, no una comprovació:
En temps d'execució no passa res: si dades no té aquesta forma, l'error apareixerà més tard i més lluny. Usar as amb la resposta d'una API és l'antipatró més habitual, i l'apartat 17 ho resol.
Les guardes de tipus són l'alternativa correcta: funcions que comproven de veritat i li ensenyen a TypeScript el resultat amb x is T.
// src/tipus/guardes.ts
import type { Bicicleta, EstatBicicleta } from './domini';
const ESTATS: readonly EstatBicicleta[] = ['disponible', 'alquilada', 'mantenimiento'];
export function esEstatBicicleta(valor: unknown): valor is EstatBicicleta {
return typeof valor === 'string' && (ESTATS as readonly string[]).includes(valor);
}
export function esBicicleta(valor: unknown): valor is Bicicleta {
if (typeof valor !== 'object' || valor === null) return false;
const b = valor as Record<string, unknown>;
return (
typeof b.id === 'string' &&
typeof b.model === 'string' &&
typeof b.preuHora === 'number' &&
esEstatBicicleta(b.estat)
);
}Ara if (esBicicleta(dades)) estreny el tipus dins del bloc, i la comprovació existeix també en execució.
- Modelar el domini:
src/tipus/domini.ts
src/tipus/domini.tsAquest és el fitxer que ancora tota la resta. Un únic lloc on viu la forma del domini de CicloUrbano.
// src/tipus/domini.ts
// ---------- Unions del domini ----------
export type TipusBicicleta = 'urbana' | 'electrica' | 'carga';
export type EstatBicicleta = 'disponible' | 'alquilada' | 'mantenimiento';
export type RolUsuari = 'cliente' | 'operario';
export type EstatReserva = 'activa' | 'finalizada' | 'cancelada';
// Identificadors amb marca de tipus: evita passar un id on va un altre.
export type BicicletaId = string & { readonly __marca: 'BicicletaId' };
export type EstacioId = string & { readonly __marca: 'EstacioId' };
// ---------- Entitats ----------
export interface Bicicleta {
id: string;
model: string;
tipus: TipusBicicleta;
estat: EstatBicicleta;
estacioId: string;
preuHora: number;
}
export interface Estacio {
id: string;
nom: string;
barri: string;
places: number;
}
export interface Usuari {
id: string;
nom: string;
correu: string;
rol: RolUsuari;
}
export interface Reserva {
id: string;
bicicletaId: string;
usuari: string;
dataInici: string; // ISO 8601
hores: number;
estat: EstatReserva;
}
// ---------- Tipus derivats ----------
// El que s'envia en crear una reserva: sense id ni estat, que els posa el servidor.
export type NovaReserva = Omit<Reserva, 'id' | 'estat'>;
// Errors de validació: una clau per camp del formulari.
export type ErrorsReserva = Partial<Record<keyof NovaReserva | 'general', string>>;
// Una bicicleta amb la seva estació ja resolta.
export interface BicicletaAmbEstacio extends Bicicleta {
estacio: Estacio;
}Val la pena aturar-se en els tipus derivats, perquè és on TypeScript deixa de ser burocràcia:
Omit<Reserva, 'id' | 'estat'>construeix el tipus del formulari a partir del de l'entitat. Si demàReservaguanya un campcodiDescompte,NovaReservael guanya automàticament. Escriure els dos tipus a mà garanteix que es desincronitzin.Partial<Record<keyof NovaReserva | 'general', string>>diu queErrorsReservapot tenir una clau per cada camp del formulari, més'general', i que totes són opcionals. Amb això,errors.horeesés un error de compilació: l'objecte d'errors del mòdul 3 passa a estar verificat.- Els identificadors amb marca són un patró avançat que impedeix passar un
estacioIdon s'espera unbicicletaId. Útil en dominis grans; opcional aquí.
- Tipar components
La forma canònica a React 19:
// src/components/EtiquetaEstat.tsx
import type { EstatBicicleta } from '../tipus/domini';
import estils from './EtiquetaEstat.module.css';
type Props = {
estat: EstatBicicleta;
compacta?: boolean;
};
const TEXTOS: Record<EstatBicicleta, string> = {
disponible: 'Disponible',
alquilada: 'Llogada',
mantenimiento: 'En manteniment',
};
function EtiquetaEstat({ estat, compacta = false }: Props) {
return (
<span
className={compacta ? estils.compacta : estils.etiqueta}
data-estat={estat}
>
{TEXTOS[estat]}
</span>
);
}
export default EtiquetaEstat;Punts a destacar:
import typeper al que només són tipus: deixa clar que aquesta importació desapareix en compilar, cosa queisolatedModulesagraeix.Record<EstatBicicleta, string>obliga queTEXTOStingui exactament les tres claus. Si afegeixes'reservada'a la unió, aquest objecte dona error fins que el completis. Aquest efecte en cascada és el major avantatge del tipatge en un domini.- Els valors per defecte van a la desestructuració, i
compacta?: booleans'infereix com abooleandins del cos.
children es tipa amb ReactNode, que és «qualsevol cosa renderitzable»:
// src/components/Panell.tsx
import type { ReactNode } from 'react';
type Props = {
titol: string;
children: ReactNode;
peu?: ReactNode;
};
function Panell({ titol, children, peu }: Props) {
return (
<section>
<h2>{titol}</h2>
<div>{children}</div>
{peu && <footer>{peu}</footer>}
</section>
);
}| Tipus | Què accepta | Quan usar-lo |
|---|---|---|
ReactNode |
Elements, cadenes, números, arrays, null |
Gairebé sempre |
ReactElement |
Només un element JSX | Quan exigeixes un únic element |
JSX.Element |
Similar | Com a tipus de retorn; rarament necessari |
Estendre atributs del DOM amb ComponentProps evita reescriure la llista d'atributs d'un <button>:
// src/components/BotoAccio.tsx
import type { ComponentProps } from 'react';
import estils from './BotoAccio.module.css';
type Props = ComponentProps<'button'> & {
variant?: 'primaria' | 'secundaria' | 'perill';
carregant?: boolean;
};
function BotoAccio({
variant = 'primaria',
carregant = false,
children,
disabled,
...resta
}: Props) {
return (
<button
className={estils[variant]}
disabled={disabled || carregant}
{...resta}
>
{carregant ? 'Enviant…' : children}
</button>
);
}
export default BotoAccio;Amb això, <BotoAccio onClick={...} type="submit" aria-label="Reservar" /> està completament tipat, inclòs l'onClick amb el seu tipus d'esdeveniment correcte, sense haver escrit cap d'aquestes props.
- Components genèrics i per què ja no s'usa
React.FC
React.FCUn component genèric és el que conserva el tipus de les dades que rep. Llista és l'exemple clàssic:
// src/components/Llista.tsx
import type { ReactNode } from 'react';
type Props<T> = {
elements: readonly T[];
clauDe: (element: T) => string;
children: (element: T, index: number) => ReactNode;
buit?: ReactNode;
};
function Llista<T>({ elements, clauDe, children, buit }: Props<T>) {
if (elements.length === 0) {
return <>{buit ?? <p>No hi ha elements.</p>}</>;
}
return (
<ul>
{elements.map((element, index) => (
<li key={clauDe(element)}>{children(element, index)}</li>
))}
</ul>
);
}
export default Llista;Ús amb dos tipus diferents, tots dos verificats:
<Llista
elements={bicicletes}
clauDe={(bici) => bici.id}
buit={<Avis tipus="info">Cap bicicleta amb aquest filtre.</Avis>}
>
{(bici) => <TargetaBicicleta bicicleta={bici} />}
</Llista>
<Llista elements={estacions} clauDe={(est) => est.id}>
{(est) => <TargetaEstacio estacio={est} />}
</Llista>TypeScript infereix T = Bicicleta en el primer cas i T = Estacio en el segon. Dins de la funció filla, bici. autocompleta els sis camps, i bici.nom dona error perquè les bicicletes no tenen nom. Sense genèrics caldria triar entre any —sense comprovació— o duplicar el component per a cada tipus.
Per què ja no s'usa React.FC
Durant anys la convenció va ser:
Raons per abandonar-lo:
| Problema | Detall |
|---|---|
Afegia children implícit |
Fins a React 18 acceptava children encara que no el declaressis. Ja no, i això va trencar molt codi |
| No admet genèrics amb comoditat | Un Llista<T> amb React.FC és incòmode |
| No aporta res | La declaració normal ja tipa props i retorn |
| Més soroll | Un tipus extra per component sense benefici |
La convenció actual, i la d'aquest curs: funció normal amb les props anotades.
- Tipar hooks
useState infereix el tipus del valor inicial i gairebé mai necessita ajuda:
const [hores, setHores] = useState(1); // number
const [obert, setObert] = useState(false); // boolean
const [tipus, setTipus] = useState('todos'); // stringEl genèric fa falta en dues situacions:
// 1. L'estat comença buit però després tindrà una altra cosa.
const [seleccionada, setSeleccionada] = useState<Bicicleta | null>(null);
// Sense el genèric seria 'null' i no admetria una bicicleta.
const [bicicletes, setBicicletes] = useState<Bicicleta[]>([]);
// Sense el genèric seria 'never[]' i no admetria elements.
// 2. El valor inicial és més estret del que vols.
const [filtre, setFiltre] = useState<TipusBicicleta | 'todos'>('todos');
// Sense el genèric seria 'string' i acceptaria qualsevol cosa.useRef té dos usos amb tipatges diferents:
// A) Referència a un node del DOM: inicial null, l'assigna React.
const campCerca = useRef<HTMLInputElement>(null);
useEffect(() => {
// .current pot ser null: cal comprovar-ho.
campCerca.current?.focus();
}, []);
<input ref={campCerca} type="search" />
// B) Valor mutable que no provoca renders (05-03).
const comptadorRenders = useRef<number>(0);
comptadorRenders.current += 1;
// C) Identificador de temporitzador.
const temporitzador = useRef<ReturnType<typeof setTimeout> | null>(null);El tipus de l'element cal encertar-lo: HTMLInputElement per a <input>, HTMLDivElement per a <div>, HTMLButtonElement per a <button>. Si no ho saps, escriu useRef<HTMLElement>(null) i l'editor et dirà el correcte tan bon punt l'assignis a un element concret.
useMemo i useCallback infereixen del cos i rarament necessiten anotació:
const disponibles = useMemo(
() => bicicletes.filter((b) => b.estat === 'disponible'),
[bicicletes]
); // Bicicleta[]
const gestionarSeleccio = useCallback((bicicletaId: string) => {
setSeleccionada(bicicletes.find((b) => b.id === bicicletaId) ?? null);
}, [bicicletes]);Fixa't que el paràmetre del useCallback sí cal anotar-lo: no hi ha context del qual inferir-lo.
useReducer: on TypeScript brilla
useReducer: on TypeScript brillaAquest és el cas que convenç els escèptics. Un reductor amb les accions com a unió discriminada aconsegueix que TypeScript sàpiga, dins de cada case, exactament quins camps porta aquesta acció.
// src/reductors/reservaFormulari.ts
import type { NovaReserva, ErrorsReserva } from '../tipus/domini';
export type EstatFormulari = {
dades: NovaReserva;
errors: ErrorsReserva;
enviant: boolean;
};
// El camp 'tipus' és el DISCRIMINANT de la unió.
export type AccioFormulari =
| { tipus: 'campCanviat'; camp: keyof NovaReserva; valor: string }
| { tipus: 'horesCanviades'; hores: number }
| { tipus: 'enviamentIniciat' }
| { tipus: 'enviamentFallat'; errors: ErrorsReserva }
| { tipus: 'enviamentCompletat' }
| { tipus: 'reiniciat'; dadesInicials: NovaReserva };
export function reductorFormulari(
estat: EstatFormulari,
accio: AccioFormulari
): EstatFormulari {
switch (accio.tipus) {
case 'campCanviat':
// Aquí TypeScript SAP que existeixen accio.camp i accio.valor,
// i que NO existeix accio.errors.
return {
...estat,
dades: { ...estat.dades, [accio.camp]: accio.valor },
errors: { ...estat.errors, [accio.camp]: undefined },
};
case 'horesCanviades':
return { ...estat, dades: { ...estat.dades, hores: accio.hores } };
case 'enviamentIniciat':
return { ...estat, enviant: true, errors: {} };
case 'enviamentFallat':
return { ...estat, enviant: false, errors: accio.errors };
case 'enviamentCompletat':
return { ...estat, enviant: false, errors: {} };
case 'reiniciat':
return { dades: accio.dadesInicials, errors: {}, enviant: false };
default: {
// EXHAUSTIVITAT: si afegeixes un cas a la unió i l'oblides tractar,
// 'accio' deixa de ser 'never' i aquesta línia falla en compilar.
const noTractada: never = accio;
throw new Error(`Acció no tractada: ${JSON.stringify(noTractada)}`);
}
}
}El truc del never mereix una explicació perquè és el patró més útil d'aquesta lliçó. Dins del default, TypeScript ha anat descartant tots els membres de la unió tractats als case anteriors. Si hi són tots, el que queda és never, i l'assignació const noTractada: never = accio compila. Si afegeixes { tipus: 'campTocat'; camp: keyof NovaReserva } a la unió i no en escrius el case, al default queda aquest membre sense tractar, no és assignable a never i la compilació falla assenyalant el reductor.
És a dir: el sistema de tipus t'obliga a mantenir el reductor complet. Cap prova unitària fa això sense que algú l'escrigui.
I al component:
const [estat, despatxar] = useReducer(reductorFormulari, estatInicial);
despatxar({ tipus: 'horesCanviades', hores: 3 }); // ✅
despatxar({ tipus: 'horesCanviades', hores: '3' }); // ❌ string no és number
despatxar({ tipus: 'horesCanviada', hores: 3 }); // ❌ tipus inexistent
despatxar({ tipus: 'enviamentFallat' }); // ❌ falta 'errors'Els tres errors es detecten a l'editor. En JavaScript, els tres passaven silenciosament i produïen un estat corromput.
useContext i hooks propis
useContext i hooks propisEl problema clàssic del context tipat: el valor per defecte. Posar createContext<Sessio | null>(null) obliga a comprovar null a cada consumidor, encara que el proveïdor sempre hi sigui present.
La solució és el hook d'accés que estreny el tipus i falla sorollosament si falta el proveïdor:
// src/contextos/ContextSessio.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import type { Usuari } from '../tipus/domini';
type ValorContextSessio = {
usuari: Usuari | null;
accedir: (correu: string, clau: string) => Promise<void>;
sortir: () => void;
};
// Sense valor per defecte real: undefined marca "no hi ha proveïdor".
const ContextSessio = createContext<ValorContextSessio | undefined>(undefined);
export function ProveidorSessio({ children }: { children: ReactNode }) {
const [usuari, setUsuari] = useState<Usuari | null>(null);
async function accedir(correu: string, clau: string) {
const resposta = await fetch('/api/acceso', {
method: 'POST',
body: JSON.stringify({ correu, clau }),
});
setUsuari(await resposta.json());
}
function sortir() {
setUsuari(null);
}
return (
<ContextSessio.Provider value={{ usuari, accedir, sortir }}>
{children}
</ContextSessio.Provider>
);
}
// El hook d'accés: estreny el tipus i dona un error clar.
export function useSessio(): ValorContextSessio {
const valor = useContext(ContextSessio);
if (valor === undefined) {
throw new Error("useSessio s'ha d'utilitzar dins de <ProveidorSessio>");
}
return valor; // aquí ja NO és undefined
}Doble benefici: els consumidors escriuen const { usuari } = useSessio() sense comprovar res, i qui oblidi el proveïdor rep un missatge explícit en lloc d'un Cannot read properties of undefined. Aquest patró ja es recomanava a 05-04; amb tipus, a més, es verifica.
Hooks propis que retornen tuples necessiten as const:
// src/hooks/useAlternar.ts
import { useState, useCallback } from 'react';
export function useAlternar(inicial = false) {
const [valor, setValor] = useState(inicial);
const alternar = useCallback(() => setValor((v) => !v), []);
const activar = useCallback(() => setValor(true), []);
const desactivar = useCallback(() => setValor(false), []);
// Sense 'as const': (boolean | (() => void))[] ← inservible
// Amb 'as const': readonly [boolean, () => void, () => void, () => void]
return [valor, alternar, activar, desactivar] as const;
}Sense as const, TypeScript infereix un array els elements del qual són la unió de tots els tipus, i en desestructurar const [obert, alternar] = useAlternar(), obert seria boolean | (() => void): inutilitzable. Amb as const infereix una tupla amb la posició i el tipus exactes.
Per a més de tres valors, retornar un objecte sol ser més llegible i no necessita el truc:
export function useMagatzemLocal<T>(clau: string, valorInicial: T) {
const [valor, setValor] = useState<T>(() => {
const desat = window.localStorage.getItem(clau);
return desat ? (JSON.parse(desat) as T) : valorInicial;
});
useEffect(() => {
window.localStorage.setItem(clau, JSON.stringify(valor));
}, [clau, valor]);
return [valor, setValor] as const;
}
// Ús: el genèric s'infereix del valor inicial.
const [favorits, setFavorits] = useMagatzemLocal<string[]>('favorits', []);
- Tipar esdeveniments
Els tipus d'esdeveniments de React són genèrics sobre l'element que els origina.
| Esdeveniment | Tipus | Ús típic |
|---|---|---|
onChange a <input> |
ChangeEvent<HTMLInputElement> |
Formularis controlats |
onChange a <select> |
ChangeEvent<HTMLSelectElement> |
SelectorTipus |
onSubmit a <form> |
FormEvent<HTMLFormElement> |
Enviament |
onClick a <button> |
MouseEvent<HTMLButtonElement> |
Botons |
onKeyDown |
KeyboardEvent<HTMLInputElement> |
useEsdevenimentTeclat |
onFocus / onBlur |
FocusEvent<HTMLInputElement> |
Validació en sortir |
import { useState, type ChangeEvent, type FormEvent } from 'react';
import type { TipusBicicleta } from '../tipus/domini';
function CercadorBicicletes({ alCercar }: { alCercar: (text: string, tipus: string) => void }) {
const [text, setText] = useState('');
const [tipus, setTipus] = useState<TipusBicicleta | 'todos'>('todos');
function gestionarText(esdeveniment: ChangeEvent<HTMLInputElement>) {
setText(esdeveniment.target.value); // string, garantit
}
function gestionarTipus(esdeveniment: ChangeEvent<HTMLSelectElement>) {
setTipus(esdeveniment.target.value as TipusBicicleta | 'todos');
}
function gestionarEnviament(esdeveniment: FormEvent<HTMLFormElement>) {
esdeveniment.preventDefault();
alCercar(text, tipus);
}
return (
<form onSubmit={gestionarEnviament}>
<input value={text} onChange={gestionarText} />
<select value={tipus} onChange={gestionarTipus}>
<option value="todos">Tots</option>
<option value="urbana">Urbana</option>
</select>
</form>
);
}Com trobar el tipus sense memoritzar-lo, que és el que de veritat cal. Escriu el gestor en línia i deixa que TypeScript infereixi; després passa el ratolí pel paràmetre i l'editor et dirà el tipus exacte:
<input onChange={(esdeveniment) => { /* passa el ratolí per 'esdeveniment' */ }} />
// Tooltip: (parameter) esdeveniment: React.ChangeEvent<HTMLInputElement>Aquest tipus es copia a la funció amb nom. És el flux de treball real: ningú memoritza aquests noms.
L'as de gestionarTipus mereix un comentari: esdeveniment.target.value és sempre string, perquè el DOM no sap res de la nostra unió. Aquí l'asserció és acceptable perquè els <option> els controlem nosaltres; en un cas més estricte usaríem la guarda esTipusBicicleta de l'apartat 8.
- Redux Toolkit i TanStack Query
Redux Toolkit està dissenyat per a TypeScript i només demana dos tipus derivats i dos hooks tipats:
// src/magatzem/index.ts
import { configureStore } from '@reduxjs/toolkit';
import sliceSessio from './sliceSessio';
import sliceCataleg from './sliceCataleg';
import sliceReserves from './sliceReserves';
export const magatzem = configureStore({
reducer: {
sessio: sliceSessio,
cataleg: sliceCataleg,
reserves: sliceReserves,
},
});
// Es DERIVEN del magatzem: mai s'escriuen a mà.
export type RootState = ReturnType<typeof magatzem.getState>;
export type AppDispatch = typeof magatzem.dispatch;// src/magatzem/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();A partir d'aquí, tot l'estat global està tipat sense més esforç:
import { useAppSelector, useAppDispatch } from '../magatzem/hooks';
import { filtreCanviat } from '../magatzem/sliceCataleg';
function SelectorTipus() {
// 'estat' està tipat: autocompleta sessio, cataleg i reserves.
const filtre = useAppSelector((estat) => estat.cataleg.filtreTipus);
const despatxar = useAppDispatch();
return (
<select value={filtre} onChange={(e) => despatxar(filtreCanviat(e.target.value))}>
{/* ... */}
</select>
);
}I l'slice es tipa anotant l'estat inicial:
// src/magatzem/sliceCataleg.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { TipusBicicleta } from '../tipus/domini';
type EstatCataleg = {
filtreTipus: TipusBicicleta | 'todos';
cerca: string;
};
const estatInicial: EstatCataleg = { filtreTipus: 'todos', cerca: '' };
const sliceCataleg = createSlice({
name: 'cataleg',
initialState: estatInicial,
reducers: {
filtreCanviat(estat, accio: PayloadAction<TipusBicicleta | 'todos'>) {
estat.filtreTipus = accio.payload;
},
cercaCanviada(estat, accio: PayloadAction<string>) {
estat.cerca = accio.payload;
},
},
});
export const { filtreCanviat, cercaCanviada } = sliceCataleg.actions;
export default sliceCataleg.reducer;PayloadAction<T> és l'única cosa que cal anotar: els creadors d'accions i els seus tipus es generen sols.
TanStack Query infereix el tipus de data a partir del retorn de queryFn:
// src/consultes/bicicletes.ts
import { useQuery } from '@tanstack/react-query';
import type { Bicicleta } from '../tipus/domini';
async function obtenirBicicletes(): Promise<Bicicleta[]> {
const resposta = await fetch('http://localhost:3001/bicicletas');
if (!resposta.ok) throw new Error("No s'ha pogut carregar el catàleg.");
return resposta.json(); // ← aquí hi ha la mentida: vegeu l'apartat 17
}
export function useBicicletes() {
return useQuery({
queryKey: ['bicicletas'],
queryFn: obtenirBicicletes,
});
}Al component, data és Bicicleta[] | undefined —undefined mentre carrega— i TypeScript obliga a tractar aquest cas. És justament la comprovació que en JavaScript s'oblidava i produïa Cannot read properties of undefined (reading 'map').
- Dades externes: validar al límit amb Zod
I ara el punt més important de la lliçó, el que separa qui usa TypeScript de qui l'entén.
La resposta d'una API no està tipada.
resposta.json()retornaany. EscriurePromise<Bicicleta[]>a la signatura no comprova res: és una promesa teva al compilador, no una verificació.
Si json-server retorna preuHora com a cadena, o el camp es reanomena al servidor, TypeScript no dirà res i el fallo apareixerà lluny, al component que fa preuHora.toFixed(2).
La solució és validar al límit: comprovar la forma de les dades una vegada, al punt on entren a l'aplicació, i a partir d'aquí confiar en els tipus.
// src/tipus/esquemes.ts
import { z } from 'zod';
export const esquemaTipusBicicleta = z.enum(['urbana', 'electrica', 'carga']);
export const esquemaEstatBicicleta = z.enum([
'disponible', 'alquilada', 'mantenimiento',
]);
export const esquemaBicicleta = z.object({
id: z.string(),
model: z.string().min(1),
tipus: esquemaTipusBicicleta,
estat: esquemaEstatBicicleta,
estacioId: z.string(),
preuHora: z.number().positive(),
});
export const esquemaLlistaBicicletes = z.array(esquemaBicicleta);
// EL TIPUS ES DERIVA DE L'ESQUEMA: una única font de veritat.
export type Bicicleta = z.infer<typeof esquemaBicicleta>;
export type TipusBicicleta = z.infer<typeof esquemaTipusBicicleta>;
export type EstatBicicleta = z.infer<typeof esquemaEstatBicicleta>;z.infer és la peça clau: el tipus es deriva de l'esquema, no s'escriu a part. És impossible que es desincronitzin.
I la consulta passa a validar de veritat:
// src/consultes/bicicletes.ts
import { useQuery } from '@tanstack/react-query';
import { esquemaLlistaBicicletes } from '../tipus/esquemes';
async function obtenirBicicletes() {
const resposta = await fetch('http://localhost:3001/bicicletas');
if (!resposta.ok) throw new Error("No s'ha pogut carregar el catàleg.");
const dades: unknown = await resposta.json();
// Valida en EXECUCIÓ i retorna un valor tipat. Si no encaixa, llança.
return esquemaLlistaBicicletes.parse(dades);
}Comparació dels dos enfocaments:
as Bicicleta[] o Promise<Bicicleta[]> |
esquema.parse(dades) |
|
|---|---|---|
| Comprovació en compilació | Sí | Sí |
| Comprovació en execució | No | Sí |
| Si l'API canvia | Fallo silenciós, lluny de l'origen | Error immediat amb el camp exacte |
| Cost | Zero | Uns microsegons per resposta |
Quan la validació falla, el missatge assenyala el problema amb precisió:
ZodError: [
{
"code": "invalid_type",
"expected": "number",
"received": "string",
"path": [1, "preuHora"],
"message": "Expected number, received string"
}
]«L'element 1 té preuHora com a cadena en lloc de número». Això és diagnòstic, no endevinació.
On convé aplicar aquesta validació a CicloUrbano:
| Límit | Validar? | Motiu |
|---|---|---|
| Respostes de l'API | Sí | El servidor pot canviar sense avisar |
localStorage (useMagatzemLocal) |
Sí | Pot tenir dades d'una versió anterior |
Paràmetres de la URL (?tipo=) |
Sí | Els escriu l'usuari |
import.meta.env |
Sí, en arrencar | Falta una variable → fallo immediat i clar |
| Props entre components propis | No | Ja les comprova TypeScript |
| Estat intern | No | Mai surt del programa |
Zod també serveix, amb el mateix esquema, per validar formularis: validarReserva del mòdul 3 es pot reescriure com a esquema i compartir les regles entre el client i el servidor —incloses les accions de servidor de 10-03—.
- TypeScript i proves: què detecta cadascun
Tornada a la piràmide, ara amb les dues capes comparades.
| Situació | Ho detecta TypeScript? | Ho detecten les proves? |
|---|---|---|
Prop mal escrita (bici en lloc de bicicleta) |
✅ En escriure | ⚠️ Només si hi ha una prova d'aquest component |
| Camp reanomenat al domini | ✅ Als 12 usos | ⚠️ Només on hi hagi cobertura |
Un case nou sense tractar al reductor |
✅ Amb el truc del never |
❌ Excepte prova específica |
Oblidar comprovar undefined |
✅ Amb strict |
⚠️ Només si la prova usa aquest cas |
| El càlcul del preu està malament | ❌ | ✅ Prova unitària |
| El botó no es deshabilita en manteniment | ❌ | ✅ Testing Library |
| La reserva no arriba a l'API | ❌ | ✅ Prova e2e |
| El text de l'error és incomprensible | ❌ | ✅ Prova d'integració |
| L'API retorna una altra forma de dades | ❌ (sí amb Zod) | ✅ Si hi ha contracte a MSW |
Un bucle infinit a useEffect |
❌ | ✅ La prova es penja |
La conclusió es llegeix a les dues columnes: TypeScript comprova la forma; les proves comproven el comportament. Substituir una per l'altra no funciona en cap direcció.
El que sí que passa és que TypeScript elimina una categoria sencera de proves trivials. Ja no cal comprovar «que el component no reventi si bicicletes és undefined»: amb strict, això no compila. Aquest temps s'inverteix a provar el comportament, que és el que importa.
I les proves també es tipen. Amb .test.tsx, Testing Library i MSW guanyen comprovació de tipus:
// src/proves/TargetaBicicleta.test.tsx
import { render, screen } from '@testing-library/react';
import TargetaBicicleta from '../components/TargetaBicicleta';
import type { Bicicleta } from '../tipus/domini';
const BICI: Bicicleta = {
id: 'bici-002',
model: 'Elèctrica Pro',
tipus: 'electrica',
estat: 'alquilada',
estacioId: 'est-01',
preuHora: 4.0,
};
test("mostra el model i l'estat de la bicicleta", () => {
render(<TargetaBicicleta bicicleta={BICI} />);
expect(screen.getByText('Elèctrica Pro')).toBeInTheDocument();
expect(screen.getByText('Llogada')).toBeInTheDocument();
});Avantatge concret: si Bicicleta guanya un camp obligatori, l'objecte de prova deixa de compilar. Les dades fictícies desactualitzades —una plaga clàssica a les suites grans— deixen de ser possibles.
- Com llegir un missatge d'error llarg
Els errors de TypeScript espanten per la seva longitud. La tècnica per llegir-los és sempre la mateixa.
Type '{ bicicleta: { id: string; model: string; tipus: string; estat: string;
estacioId: string; preuHora: number; }; }' is not assignable to type
'IntrinsicAttributes & Props'.
Types of property 'bicicleta' are not assignable.
Type '{ id: string; model: string; tipus: string; estat: string; ... }' is
not assignable to type 'Bicicleta'.
Types of property 'tipus' are not assignable.
Type 'string' is not assignable to type 'TipusBicicleta'.Llegeix-lo de baix a dalt. L'última línia és la causa; les de dalt són el camí fins a ella.
- Última línia:
'string' no és assignable a 'TipusBicicleta'. Aquí hi ha el problema real. - Penúltima: passa a la propietat
tipus. - Anteriors: dins de l'objecte passat a la prop
bicicleta.
Diagnòstic: en algun lloc es va crear una bicicleta el tipus de la qual és string en lloc de la unió. Causa habitual: un objecte literal sense anotar, o dades vingudes de l'API sense validar.
// Origen del problema
const bici = { id: 'bici-002', tipus: 'electrica', /* ... */ };
// 'tipus' s'infereix com a 'string'
// Solució A: anotar l'objecte
const bici: Bicicleta = { id: 'bici-002', tipus: 'electrica', /* ... */ };
// Solució B: as const en el valor
const bici = { id: 'bici-002', tipus: 'electrica' as const, /* ... */ };Errors freqüents de qui comença, amb la seva traducció:
| Missatge | Què significa realment |
|---|---|
Object is possibly 'undefined' |
Falta comprovar abans d'usar: ?. o un if |
Property 'x' does not exist on type 'y' |
Nom mal escrit, o el tipus és més estret del que creies |
Type 'string' is not assignable to type '"a" | "b"' |
Es va usar string on va una unió de literals |
Argument of type 'X' is not assignable to parameter of type 'Y' |
Argument amb la forma equivocada; compara camp a camp |
Cannot find module './X' or its type declarations |
Falta l'extensió, o el fitxer encara no està migrat |
Type 'never[]' is not assignable... |
useState([]) sense genèric |
JSX element type does not have any construct signatures |
Es va usar un valor no component com a component |
Errors Habituals i Consells
- Començar sense
strict. SensestrictNullChecks, TypeScript no detecta la major part dels fallos reals. Activa'l des del principi; afegir-lo a un projecte gran després és dolorós. - Usar
anyper sortir del pas. Anul·la les comprovacions en cascada. Si no saps el tipus,unknowni estreny. - Confiar en
asamb dades de l'API. No comprova res en execució. Valida al límit amb Zod i deriva el tipus de l'esquema. - Creure que Vite verifica els tipus. No ho fa.
tsc --noEmitalbuildi a la integració contínua, o el tipatge és decoratiu. - Escriure
BicicletaiesquemaBicicletaper separat. Es desincronitzen.z.inferderiva l'un de l'altre. - Escriure
RootStatea mà. Es deriva ambReturnType<typeof magatzem.getState>. Escrit a mà, es desactualitza en afegir un slice. - Oblidar
as consten un hook que retorna tupla. El tipus es converteix en un array d'unions i la desestructuració deixa de funcionar. - Usar
React.FC. Convenció obsoleta: no aporta res i entorpeix els genèrics. - Reanomenar a
.tsun fitxer amb JSX. Ha de ser.tsx, o el compilador interpreta<Component>com una asserció de tipus i produeix errors incomprensibles. - Migrar de l'arrel cap a les fulles. Al revés: primer els tipus del domini, després utilitats i hooks, després components. Si no, tot el que s'importa arriba com a
any. - Consell: passa el ratolí per sobre. L'editor et diu el tipus inferit de qualsevol expressió. És més ràpid i més fiable que buscar a la documentació.
- Consell: activa
noUncheckedIndexedAccess. És incòmoda les dues primeres setmanes i evita una família sencera d'errors en producció.
Exercicis
Exercici 1. Tipa aquest component de CicloUrbano, que avui està en JavaScript. Defineix el tipus de props, usa els tipus del domini i corregeix els dos problemes de tipus que apareixeran en fer-ho.
// src/components/PanellReserva.jsx
function PanellReserva({ bicicleta, hores, alConfirmar, descompte }) {
const total = bicicleta.preuHora * hores * (1 - descompte);
const potReservar = bicicleta.estat === 'disponible';
return (
<aside>
<p>Total estimat: {total.toFixed(2)} €</p>
<button onClick={() => alConfirmar(bicicleta.id, hores)} disabled={!potReservar}>
Reservar {hores} h
</button>
</aside>
);
}Exercici 2. Escriu el reductor tipat de PanellReserves, que gestiona la llista de reserves de l'usuari. Ha de suportar: carregar la llista, marcar una reserva com a cancel·lada, filtrar per estat i registrar un error. Defineix EstatReserves i la unió discriminada AccioReserves, inclou el cas never d'exhaustivitat, i demostra amb un exemple què passa en afegir una acció nova sense tractar-la.
Exercici 3. L'API de CicloUrbano ha començat a retornar places com a cadena en algunes estacions i a vegades falta barri. Escriu l'esquema de Zod d'Estacio que: validi els camps, converteixi places a número encara que arribi com a cadena, doni a barri el valor 'Sense assignar' quan no vingui, i derivi el tipus Estacio. Escriu també la funció de consulta que l'usa i explica què passa exactament quan arriba una dada invàlida.
Solucions
Solució 1.
// src/components/PanellReserva.tsx
import type { Bicicleta } from '../tipus/domini';
type Props = {
bicicleta: Bicicleta;
hores: number;
alConfirmar: (bicicletaId: string, hores: number) => void;
descompte?: number; // opcional: no sempre hi ha descompte
};
function PanellReserva({ bicicleta, hores, alConfirmar, descompte = 0 }: Props) {
const total = bicicleta.preuHora * hores * (1 - descompte);
const potReservar = bicicleta.estat === 'disponible';
return (
<aside>
<p>Total estimat: {total.toFixed(2)} €</p>
<button
type="button"
onClick={() => alConfirmar(bicicleta.id, hores)}
disabled={!potReservar}
>
Reservar {hores} h
</button>
</aside>
);
}
export default PanellReserva;Els dos problemes que apareixen en tipar:
descomptepot no passar-se. En JavaScript, ometre-la feia1 - undefined = NaNi el total es mostrava com «NaN €», un fallo silenciós. En tipar, o es declara obligatòria o se li dona un valor per defecte. Aquí el correcte ésdescompte = 0.alConfirmarnecessita una signatura explícita. Sense ella seriaFunction, i qualsevol crida amb arguments equivocats compilaria. Amb(bicicletaId: string, hores: number) => void, invertir l'ordre dels arguments és un error de compilació.
Com a extra, bicicleta.estat === 'disponible' queda verificat contra la unió: escriure 'Disponible' donaria error, perquè no és un valor possible d'EstatBicicleta.
Solució 2.
// src/reductors/reserves.ts
import type { Reserva, EstatReserva } from '../tipus/domini';
export type EstatReserves = {
reserves: Reserva[];
filtre: EstatReserva | 'totes';
carregant: boolean;
error: string | null;
};
export type AccioReserves =
| { tipus: 'carregaIniciada' }
| { tipus: 'carregaCompletada'; reserves: Reserva[] }
| { tipus: 'carregaFallada'; missatge: string }
| { tipus: 'reservaCancellada'; reservaId: string }
| { tipus: 'filtreCanviat'; filtre: EstatReserva | 'totes' };
export const estatInicial: EstatReserves = {
reserves: [],
filtre: 'totes',
carregant: false,
error: null,
};
export function reductorReserves(
estat: EstatReserves,
accio: AccioReserves
): EstatReserves {
switch (accio.tipus) {
case 'carregaIniciada':
return { ...estat, carregant: true, error: null };
case 'carregaCompletada':
return { ...estat, carregant: false, reserves: accio.reserves };
case 'carregaFallada':
return { ...estat, carregant: false, error: accio.missatge };
case 'reservaCancellada':
return {
...estat,
reserves: estat.reserves.map((reserva) =>
reserva.id === accio.reservaId
? { ...reserva, estat: 'cancelada' }
: reserva
),
};
case 'filtreCanviat':
return { ...estat, filtre: accio.filtre };
default: {
const noTractada: never = accio;
throw new Error(`Acció no tractada: ${JSON.stringify(noTractada)}`);
}
}
}Què passa en afegir una acció sense tractar-la. Si amplies la unió:
export type AccioReserves =
| /* ... les cinc d'abans ... */
| { tipus: 'reservaAmpliada'; reservaId: string; horesExtra: number };i no n'escrius el case, al default el tipus d'accio ja no s'ha reduït a never, sinó a { tipus: 'reservaAmpliada'; ... }, i la compilació falla:
Type '{ tipus: "reservaAmpliada"; reservaId: string; horesExtra: number; }'
is not assignable to type 'never'.L'error apunta a la línia exacta del reductor. És una comprovació de completitud gratuïta que cap prova proporciona sense escriure-la explícitament. Fixa't a més en { ...reserva, estat: 'cancelada' }: si escrivissis 'cancelado', TypeScript ho rebutjaria per no pertànyer a EstatReserva.
Solució 3.
// src/tipus/esquemes.ts
import { z } from 'zod';
export const esquemaEstacio = z.object({
id: z.string(),
nom: z.string().min(1),
// barri pot faltar: se li dona un valor per defecte.
barri: z.string().default('Sense assignar'),
// places pot arribar com a número o com a cadena numèrica.
places: z.union([
z.number().int().nonnegative(),
z.string().regex(/^\d+$/).transform(Number),
]),
});
export const esquemaLlistaEstacions = z.array(esquemaEstacio);
// El tipus derivat ja té places: number i barri: string (no opcional).
export type Estacio = z.infer<typeof esquemaEstacio>;// src/consultes/estacions.ts
import { useQuery } from '@tanstack/react-query';
import { esquemaLlistaEstacions } from '../tipus/esquemes';
async function obtenirEstacions() {
const resposta = await fetch('http://localhost:3001/estaciones');
if (!resposta.ok) throw new Error("No s'han pogut carregar les estacions.");
const dades: unknown = await resposta.json();
return esquemaLlistaEstacions.parse(dades);
}
export function useEstacions() {
return useQuery({ queryKey: ['estaciones'], queryFn: obtenirEstacions });
}Què passa quan arriba una dada invàlida —per exemple, places: "vint"—:
parsellança unZodErroramb la ruta exacta:[1, "places"], és a dir, la segona estació.- En llançar-se dins de
queryFn, TanStack Query ho tracta com un error de consulta:isErrorpassa atrueidatacontinua sentundefined. - El component mostra la seva interfície d'error —o el
LimitErrorsi s'usauseSuspenseQuery, segons 10-03— en lloc de pintarNaN. - L'error queda registrat amb el camp culpable, així que el diagnòstic és immediat.
Dos matisos sobre el disseny d'aquest esquema. default('Sense assignar') fa que el tipus derivat tingui barri: string no opcional, així que els components no necessiten comprovar undefined: la incertesa s'ha resolt al límit, que és exactament l'objectiu. I si preferissis tolerar errors parcials en lloc de fer fallar la consulta sencera, safeParse retorna { success, data, error } sense llançar, cosa que permet descartar les estacions invàlides i mostrar la resta.
Conclusió
Aquesta lliçó ha completat la base de la piràmide que 09-01 va deixar a mitges. El linter cobria l'estil i els errors de forma del codi; TypeScript cobreix els contractes, i ho fa en el moment més barat possible: mentre escrius, amb el cursor a la línia de l'error.
De la posada en marxa, l'essencial és que TypeScript entra en un projecte existent sense reescriure'l: typescript més @types/react i @types/react-dom, un tsconfig.json amb strict: true —innegociable—, jsx: "react-jsx", moduleResolution: "bundler", allowJs: true per a la convivència i noUncheckedIndexedAccess perquè l'accés per índex digui la veritat. I un avís que decideix si tot això serveix d'alguna cosa: Vite no comprova els tipus, així que tsc --noEmit ha d'estar al build i a la integració contínua. La migració va de les fulles a l'arrel —domini, utilitats, hooks, components fulla, compostos, estat global, pàgines—, en branques petites, amb les proves del mòdul 9 fent de xarxa.
Del llenguatge, el subconjunt que s'usa a diari és curt: primitius amb inferència, unions de literals —l'eina més rendible del domini—, opcionals, arrays, genèrics bàsics, interface per a entitats i type per a tota la resta, i unknown en lloc d'any amb guardes de tipus x is T per estrènyer de veritat. Queda fixat src/tipus/domini.ts amb Bicicleta, Estacio, Usuari, Reserva, TipusBicicleta, EstatBicicleta, RolUsuari i EstatReserva, més els derivats NovaReserva amb Omit i ErrorsReserva amb Partial<Record<...>>, que fan que ampliar una entitat propagui el canvi a tot el que en depèn.
A React, les peces queden així: props amb type i valors per defecte a la desestructuració; children amb ReactNode; atributs del DOM heretats amb ComponentProps<'button'>; components genèrics com Llista<T> que conserven el tipus de l'element; i res de React.FC. En hooks, useState infereix excepte quan comença en null o en []; useRef<HTMLInputElement>(null) per al DOM i useRef<number>(0) per a valors mutables; useContext sense valor per defecte més un hook d'accés que estreny el tipus i falla sorollosament si falta el proveïdor; i as const en els hooks propis que retornen tuples. El cas on TypeScript brilla de veritat és useReducer amb les accions com a unió discriminada: dins de cada case es coneixen els camps exactes, i el truc del never al default converteix «he oblidat tractar una acció» en un error de compilació. Els esdeveniments no es memoritzen: s'escriuen en línia, es llegeix el tipus que mostra l'editor i es copia.
A les biblioteques, Redux Toolkit només demana derivar RootState i AppDispatch del magatzem i crear useAppSelector/useAppDispatch tipats; TanStack Query infereix data del queryFn i obliga a tractar l'undefined de la càrrega. I el punt que més canvia la manera de treballar: la resposta d'una API no està tipada, així que es valida al límit amb Zod i el tipus es deriva de l'esquema amb z.infer, en una única font de veritat. Tot el que entra des de fora —API, localStorage, paràmetres de la URL, variables d'entorn— passa per aquest filtre; el que circula entre components propis ja ho comprova TypeScript.
I l'equilibri final, que és la resposta a la pregunta implícita de 09-01: TypeScript comprova la forma; les proves comproven el comportament. Els tipus detecten la prop mal escrita, el camp reanomenat als seus dotze usos i el case que falta; no detecten que el càlcul del preu estigui malament, que el botó no es deshabiliti en manteniment o que el missatge d'error sigui incomprensible. El que sí que fan és eliminar una categoria sencera de proves trivials, alliberant aquest temps per provar el que importa.
Curs de React
Mòdul 1: Introducció a React
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
