In 09-01, when building the testing pyramid, static analysis was placed at its base and called "the cheapest level": checks that run without launching the application, without mounting components, and without waiting for anything. There it was covered by the linter, and it was announced that the other half — types — would be covered in this lesson. And module 10 has reinforced the argument without naming it: in 10-03 you saw contracts everywhere — which props can cross a boundary, what shape the object returned by a server action has, which fields a Bike carries — and all of them are, today, implicit: they live in the head of whoever wrote the component and are only discovered when something fails at runtime. This lesson makes them explicit and checks them as you type. You are going to set up TypeScript on the CicloUrbano Vite project incrementally, model the domain in a single types file, and type components, hooks, events, Redux Toolkit, TanStack Query, and API responses. The goal isn't to learn all of TypeScript, but the subset used every day in React, and why.

Contents

  1. Static analysis as the cheapest layer
  2. What TypeScript brings and what it costs
  3. Setting up on the Vite project
  4. tsconfig.json: the options that matter
  5. Incremental migration strategy
  6. Fundamentals used every day
  7. interface vs type
  8. unknown, any, assertions, and type guards
  9. Modeling the domain: src/types/domain.ts
  10. Typing components
  11. Generic components and why React.FC is no longer used
  12. Typing hooks
  13. useReducer: where TypeScript shines
  14. useContext and custom hooks
  15. Typing events
  16. Redux Toolkit and TanStack Query
  17. External data: validating at the boundary with Zod
  18. TypeScript and tests: what each one catches
  19. How to read a long error message

  1. Static analysis as the cheapest layer

Recall the pyramid from 09-01 and look at its base:

Level Execution cost What it catches When it warns
Static analysis Milliseconds, in the editor Shape errors: names, types, misused hooks While you type
Unit Milliseconds Pure function logic When running npm test
Integration Tenths of a second Component behavior When running npm test
End-to-end Seconds The whole system In continuous integration

The property that makes static analysis special is when it warns. A test tells you something is wrong after you write it, run it, and wait. Typing tells you before you save the file, with the cursor still on the line of the error. That difference of seconds is huge in practice, because the context is still in your head.

A real example from CicloUrbano. This change would pass every unit test for validateBooking and still break the application:

// Someone renames the field in the data model.
// Before: { id, model, type, status, stationId, pricePerHour }
// Now: { id, model, type, status, stationId, hourlyPrice }

Without types, bike.pricePerHour returns undefined in the twelve places where it's used, and the interface shows "NaN €/h". You find out when someone opens that screen. With types, the editor flags all twelve places the moment you rename the field, and tsc fails the build before you deploy.

  1. What TypeScript brings and what it costs

Let's be concrete in both directions.

What it brings:

Benefit Example in CicloUrbano
Editor errors <BikeCard bike={...} /> when the prop is actually called bicycle
Explicit contracts Opening BikeCard.tsx shows which props it accepts without reading the body
Real autocomplete bike. offers the six fields, not an empty list
Safe refactoring Renaming pricePerHour updates every usage and flags the ones that don't fit
Living documentation The type doesn't go stale like a comment
Exhaustive narrowing A switch over status warns you if you add 'reserved' and forget a case

What it costs:

Cost Reality
Initial setup An afternoon on an existing project
Learning curve Real: generics, unions, and utility types take weeks
Third-party types Almost the whole ecosystem ships them; the odd old library forces you to write your own
Verbosity Offset by inference: most of it goes unwritten
Build time Vite doesn't type-check while serving; tsc --noEmit is a separate step

An important clarification on that last row, because it's surprising: Vite doesn't verify types. It strips the annotations with esbuild and moves on. The real checking is done by your editor and the tsc --noEmit command, which has to run in continuous integration. If you don't, TypeScript becomes decoration.

And the honest caveat: TypeScript doesn't replace tests. It guarantees that pricePerHour is a number, not that the price calculation is correct. They are different, complementary layers; section 18 covers this in detail.

  1. Setting up on the Vite project

On the existing CicloUrbano project, without touching anything else:

npm install -D typescript @types/react @types/react-dom

What each package is:

  • typescript: the compiler and the language service your editor uses.
  • @types/react and @types/react-dom: React's type definitions. React ships without built-in types, so they come separately. They must match React 19.

Add the check command to package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "types": "tsc --noEmit",
    "test": "vitest",
    "lint": "eslint ."
  }
}

tsc --noEmit means "check the types but don't generate JavaScript": Vite already handles compiling. Chaining it before vite build turns a type error into a build failure, exactly like the linter in 09-01.

  1. tsconfig.json: the options that matter

At the project root:

{
  "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"]
}

The ones you really need to understand:

strict: true — turns on a group of checks. The most important is strictNullChecks: without it, null and undefined are assignable to any type, and TypeScript loses half its value.

// With strict enabled:
function findBike(id: string): Bike | undefined { /* ... */ }

const bike = findBike('bici-002');
console.log(bike.model);
//          ~~~~ Error: 'bike' is possibly 'undefined'

// Forced to check it:
if (bike) console.log(bike.model);   // ✅
console.log(bike?.model);            // ✅

That error is a TypeError: Cannot read properties of undefined that will never reach production. Starting without strict is the decision people regret the most later.

jsx: "react-jsx" — the modern transform. It lets you write JSX without importing React in every file. With "react" (the old one), import React from 'react' becomes mandatory again.

moduleResolution: "bundler" — tells TypeScript to resolve modules the way Vite does: without requiring the .js extension on imports, and respecting packages' exports field. It's the right choice in a bundler-based project.

allowJs: truethe key to the incremental migration. It lets .js/.jsx and .ts/.tsx coexist and import each other. With checkJs: false, JavaScript files aren't checked: you migrate one at a time, no big bang.

noUncheckedIndexedAccess: true — highly recommended and little known. It makes indexed access return T | undefined, which is the truth:

const bikes: Bike[] = [];
const first = bikes[0];
// Without the option: Bike  ← a lie, the array can be empty
// With the option: Bike | undefined  ← the truth

console.log(first.model);   // Error with the option enabled
console.log(first?.model);  // ✅

isolatedModules: true — mandatory with Vite. It requires every file to be transpilable on its own, which in exchange requires export type / import type for pure types.

skipLibCheck: true — skips checking dependencies' .d.ts files. It saves a lot of time and avoids errors in code you don't control.

And the Vite environment types file, at src/vite-env.d.ts:

/// <reference types="vite/client" />

Without it, import.meta.env.VITE_URL_API isn't typed.

  1. Incremental migration strategy

There's one rule: from the leaves to the root. You start with code that has no dependencies and work your way up.

flowchart TB
    A["1. Domain types<br/>src/types/domain.ts"] --> B["2. Pure utilities<br/>validateBooking, classNames, availability"]
    B --> C["3. Custom hooks<br/>useToggle, useDebounce, useLocalStorage"]
    C --> D["4. Leaf components<br/>StatusBadge, BikeCard"]
    D --> E["5. Composite components<br/>BikeList, BookingPanel"]
    E --> F["6. Global state<br/>store, slices, queries"]
    F --> G["7. Pages and routes"]

Why that order: a typed component that imports an untyped utility receives any, and the typing is worthless. If the utility is already typed, the component inherits useful information from the very first moment.

The per-file procedure, mechanically:

  1. Rename: .js.ts, .jsx.tsx. A file with JSX must be .tsx, no exceptions.
  2. Run npm run types and read the errors.
  3. Annotate the minimum: function parameters and component props. Return types are almost always inferred.
  4. Update the imports in the files that use it (with moduleResolution: "bundler" you usually don't need to touch anything).
  5. Run module 9's tests: they are the safety net that makes the migration safe.

Three field tips:

  • Migrate in small branches. One file, or a small group, per change. An 80-file migration all at once is unreviewable.
  • Ban any from day one, with the @typescript-eslint/no-explicit-any rule. An any isn't a type: it's a hole that voids the checks on everything it touches.
  • If you get stuck, use unknown and narrow it afterward. It's the honest way out; any is the dishonest one.

  1. Fundamentals used every day

The subset of TypeScript that shows up in a real React project is surprisingly small.

Primitives and inference. You almost never need to annotate a variable:

const model = 'Classic Urban';     // string, inferred
const price = 2.5;                 // number
const available = true;            // boolean

// Only annotate when inference isn't enough:
let selectedStation: string | null = null;

Literal unions, the domain's most cost-effective tool:

type BikeStatus = 'disponible' | 'alquilada' | 'mantenimiento';

let status: BikeStatus = 'disponible';
status = 'averiada';
//        ~~~~~~~~~~ Error: not assignable to BikeStatus

Compare that with status: string, which accepts 'DISPONIBLE', 'disponible ' with a trailing space, and 'plátano'. The literal union turns a data error into a compile-time error, and it also makes the editor autocomplete the three valid values.

Arrays and optionals:

const models: string[] = ['Classic Urban', 'Electric Pro'];
const prices: Array<number> = [2.5, 4.0, 5.5];  // equivalent syntax

interface Filters {
  type?: BikeType;    // may be absent: BikeType | undefined
  onlyAvailable: boolean;
}

Functions:

// Annotated parameters, return type inferred as number.
function calculatePrice(pricePerHour: number, hours: number) {
  return pricePerHour * hours;
}

// With a default value and an explicit return type when it adds clarity.
function formatPrice(price: number, currency: string = '€'): string {
  return `${price.toFixed(2)} ${currency}`;
}

// The type of a function, useful for props.
type OnSelect = (bicicletaId: string) => void;

Basic generics. A generic is a type that gets decided at the point of use:

// Without a generic: the element's type is lost.
function firstBad(list: unknown[]): unknown { return list[0]; }

// With a generic: it's preserved.
function first<T>(list: T[]): T | undefined {
  return list[0];
}

const b = first(bikes);        // Bike | undefined
const n = first([1, 2, 3]);    // number | undefined

T isn't magic: it's a type parameter, just like list is a value parameter.

  1. interface vs type

Both declare the shape of an object, and in 90% of cases they're interchangeable.

interface Bike {
  id: string;
  model: string;
  pricePerHour: number;
}

type BikeAlt = {
  id: string;
  model: string;
  pricePerHour: number;
};

Real differences:

interface type
Objects Yes Yes
Unions ('a' | 'b') No Yes
Tuples, primitives, functions Limited Yes
Extending extends Intersection &
Declaration merging Yes No
Error messages Tend to be more readable Sometimes expand a lot

Declaration merging is the deciding factor: two interfaces with the same name merge silently. It's essential for extending library types, and dangerous for your own types, because a repeated name doesn't produce an error.

This course's practical convention:

  • interface for the shape of domain entities, which can grow.
  • type for unions, aliases, component props, and everything else.

  1. unknown, any, assertions, and type guards

any turns TypeScript off in everything it touches:

const data: any = await response.json();
data.bikes.map((b) => b.pricePerHour * 2);  // nothing is checked
data.this.does.not.exist.either;            // no error either

unknown is the honest unknown value: it can't be used without checking it first.

const data: unknown = await response.json();
data.bikes;
//    ~~~~~~~~~~ Error: 'data' is of type 'unknown'

if (typeof data === 'object' && data !== null && 'bikes' in data) {
  // it's safe to work with it here
}
any unknown
Anything can be assigned to it Yes Yes
Can be used without checking Yes (dangerous) No
Propagates the loss of checking Yes No
When to use it Almost never External data, catch, migrations

Assertions (as) are a promise, not a check:

const bike = data as Bike;  // "trust me"

Nothing happens at runtime: if data doesn't actually have that shape, the error will show up later, and farther from its source. Using as on an API response is the most common antipattern, and section 17 fixes it.

Type guards are the correct alternative: functions that actually check, and teach TypeScript the result with x is T.

// src/types/guards.ts
import type { Bike, BikeStatus } from './domain';

const STATUSES: readonly BikeStatus[] = ['disponible', 'alquilada', 'mantenimiento'];

export function isBikeStatus(value: unknown): value is BikeStatus {
  return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
}

export function isBike(value: unknown): value is Bike {
  if (typeof value !== 'object' || value === null) return false;
  const b = value as Record<string, unknown>;
  return (
    typeof b.id === 'string' &&
    typeof b.model === 'string' &&
    typeof b.pricePerHour === 'number' &&
    isBikeStatus(b.status)
  );
}

Now if (isBike(data)) narrows the type inside the block, and the check also exists at runtime.

  1. Modeling the domain: src/types/domain.ts

This is the file everything else anchors to. A single place where the shape of CicloUrbano's domain lives.

// src/types/domain.ts

// ---------- Domain unions ----------

export type BikeType = 'urbana' | 'electrica' | 'carga';
export type BikeStatus = 'disponible' | 'alquilada' | 'mantenimiento';
export type UserRole = 'cliente' | 'operario';
export type BookingStatus = 'activa' | 'finalizada' | 'cancelada';

// Branded identifiers: prevents passing one id where another is expected.
export type BikeId = string & { readonly __brand: 'BikeId' };
export type StationId = string & { readonly __brand: 'StationId' };

// ---------- Entities ----------

export interface Bike {
  id: string;
  model: string;
  type: BikeType;
  status: BikeStatus;
  stationId: string;
  pricePerHour: number;
}

export interface Station {
  id: string;
  name: string;
  district: string;
  docks: number;
}

export interface User {
  id: string;
  name: string;
  email: string;
  role: UserRole;
}

export interface Booking {
  id: string;
  bicicletaId: string;
  user: string;
  startDate: string;   // ISO 8601
  hours: number;
  status: BookingStatus;
}

// ---------- Derived types ----------

// What's sent when creating a booking: no id or status, the server sets those.
export type NewBooking = Omit<Booking, 'id' | 'status'>;

// Validation errors: one key per form field.
export type BookingErrors = Partial<Record<keyof NewBooking | 'general', string>>;

// A bike with its station already resolved.
export interface BikeWithStation extends Bike {
  station: Station;
}

The derived types are worth pausing on, because that's where TypeScript stops being bureaucracy:

  • Omit<Booking, 'id' | 'status'> builds the form's type from the entity's. If tomorrow Booking gains a discountCode field, NewBooking gains it automatically. Writing both types by hand guarantees they'll drift apart.
  • Partial<Record<keyof NewBooking | 'general', string>> says BookingErrors can have one key per form field, plus 'general', and that all of them are optional. With this, errors.huors is a compile-time error: module 3's error object is now verified.
  • Branded identifiers are an advanced pattern that keeps you from passing a stationId where a bicicletaId is expected. Useful in large domains; optional here.

  1. Typing components

The canonical form in React 19:

// src/components/StatusBadge.tsx
import type { BikeStatus } from '../types/domain';
import styles from './StatusBadge.module.css';

type Props = {
  status: BikeStatus;
  compact?: boolean;
};

const LABELS: Record<BikeStatus, string> = {
  disponible: 'Available',
  alquilada: 'Rented',
  mantenimiento: 'In maintenance',
};

function StatusBadge({ status, compact = false }: Props) {
  return (
    <span
      className={compact ? styles.compact : styles.badge}
      data-status={status}
    >
      {LABELS[status]}
    </span>
  );
}

export default StatusBadge;

Points worth calling out:

  • import type for anything that's only a type: it makes clear that import disappears at compile time, which isolatedModules appreciates.
  • Record<BikeStatus, string> forces LABELS to have exactly the three keys. If you add 'reserved' to the union, this object errors out until you fill it in. That cascading effect is typing's biggest win in a domain.
  • Default values go in the destructuring, and compact?: boolean is inferred as boolean inside the body.

children is typed with ReactNode, which is "anything renderable":

// src/components/Panel.tsx
import type { ReactNode } from 'react';

type Props = {
  title: string;
  children: ReactNode;
  footer?: ReactNode;
};

function Panel({ title, children, footer }: Props) {
  return (
    <section>
      <h2>{title}</h2>
      <div>{children}</div>
      {footer && <footer>{footer}</footer>}
    </section>
  );
}
Type What it accepts When to use it
ReactNode Elements, strings, numbers, arrays, null Almost always
ReactElement Only a JSX element When you require a single element
JSX.Element Similar As a return type; rarely needed

Extending DOM attributes with ComponentProps avoids rewriting the attribute list of a <button>:

// src/components/ActionButton.tsx
import type { ComponentProps } from 'react';
import styles from './ActionButton.module.css';

type Props = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary' | 'danger';
  loading?: boolean;
};

function ActionButton({
  variant = 'primary',
  loading = false,
  children,
  disabled,
  ...rest
}: Props) {
  return (
    <button
      className={styles[variant]}
      disabled={disabled || loading}
      {...rest}
    >
      {loading ? 'Sending…' : children}
    </button>
  );
}

export default ActionButton;

With this, <ActionButton onClick={...} type="submit" aria-label="Book" /> is fully typed, including onClick with its correct event type, without you having written any of those props.

  1. Generic components and why React.FC is no longer used

A generic component is one that preserves the type of the data it receives. List is the classic example:

// src/components/List.tsx
import type { ReactNode } from 'react';

type Props<T> = {
  items: readonly T[];
  keyFor: (item: T) => string;
  children: (item: T, index: number) => ReactNode;
  empty?: ReactNode;
};

function List<T>({ items, keyFor, children, empty }: Props<T>) {
  if (items.length === 0) {
    return <>{empty ?? <p>No items.</p>}</>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyFor(item)}>{children(item, index)}</li>
      ))}
    </ul>
  );
}

export default List;

Used with two different types, both checked:

<List
  items={bikes}
  keyFor={(bike) => bike.id}
  empty={<Notice type="info">No bikes match that filter.</Notice>}
>
  {(bike) => <BikeCard bike={bike} />}
</List>

<List items={stations} keyFor={(station) => station.id}>
  {(station) => <StationCard station={station} />}
</List>

TypeScript infers T = Bike in the first case and T = Station in the second. Inside the child function, bike. autocompletes the six fields, and bike.name errors out because bikes don't have a name. Without generics you'd have to choose between any — no checking — or duplicating the component for every type.

Why React.FC is no longer used

For years the convention was:

// Old style: avoid it
const StatusBadge: React.FC<Props> = ({ status }) => { /* ... */ };

Reasons to abandon it:

Problem Detail
Implicitly added children Up through React 18 it accepted children even if you didn't declare it. Not anymore, and that broke a lot of code
Doesn't play well with generics A List<T> with React.FC is awkward
Adds nothing A normal declaration already types props and return
More noise An extra type per component with no benefit

The current convention, and this course's: a normal function with annotated props.

  1. Typing hooks

useState infers the type from the initial value and almost never needs help:

const [hours, setHours] = useState(1);      // number
const [open, setOpen] = useState(false);    // boolean
const [type, setType] = useState('todos');  // string

The generic is needed in two situations:

// 1. The state starts empty but will later hold something else.
const [selected, setSelected] = useState<Bike | null>(null);
// Without the generic it would be 'null' and wouldn't accept a bike.

const [bikes, setBikes] = useState<Bike[]>([]);
// Without the generic it would be 'never[]' and wouldn't accept elements.

// 2. The initial value is narrower than what you want.
const [filter, setFilter] = useState<BikeType | 'todos'>('todos');
// Without the generic it would be 'string' and accept anything.

useRef has two uses with different typings:

// A) Reference to a DOM node: starts as null, React assigns it.
const searchField = useRef<HTMLInputElement>(null);

useEffect(() => {
  // .current can be null: you have to check it.
  searchField.current?.focus();
}, []);

<input ref={searchField} type="search" />

// B) Mutable value that doesn't trigger renders (05-03).
const renderCount = useRef<number>(0);
renderCount.current += 1;

// C) Timer identifier.
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

You have to get the element type right: HTMLInputElement for <input>, HTMLDivElement for <div>, HTMLButtonElement for <button>. If you don't know it, write useRef<HTMLElement>(null) and the editor will tell you the correct one as soon as you assign it to a specific element.

useMemo and useCallback infer from the body and rarely need an annotation:

const available = useMemo(
  () => bikes.filter((b) => b.status === 'disponible'),
  [bikes]
);  // Bike[]

const handleSelect = useCallback((bicicletaId: string) => {
  setSelected(bikes.find((b) => b.id === bicicletaId) ?? null);
}, [bikes]);

Notice that the useCallback parameter does need annotating: there's no context to infer it from.

  1. useReducer: where TypeScript shines

This is the case that wins over skeptics. A reducer whose actions form a discriminated union lets TypeScript know, inside each case, exactly which fields that action carries.

// src/reducers/bookingForm.ts
import type { NewBooking, BookingErrors } from '../types/domain';

export type FormState = {
  data: NewBooking;
  errors: BookingErrors;
  submitting: boolean;
};

// The 'type' field is the union's DISCRIMINANT.
export type FormAction =
  | { type: 'fieldChanged'; field: keyof NewBooking; value: string }
  | { type: 'hoursChanged'; hours: number }
  | { type: 'submitStarted' }
  | { type: 'submitFailed'; errors: BookingErrors }
  | { type: 'submitCompleted' }
  | { type: 'reset'; initialData: NewBooking };

export function formReducer(
  state: FormState,
  action: FormAction
): FormState {
  switch (action.type) {
    case 'fieldChanged':
      // Here TypeScript KNOWS that action.field and action.value exist,
      // and that action.errors does NOT.
      return {
        ...state,
        data: { ...state.data, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: undefined },
      };

    case 'hoursChanged':
      return { ...state, data: { ...state.data, hours: action.hours } };

    case 'submitStarted':
      return { ...state, submitting: true, errors: {} };

    case 'submitFailed':
      return { ...state, submitting: false, errors: action.errors };

    case 'submitCompleted':
      return { ...state, submitting: false, errors: {} };

    case 'reset':
      return { data: action.initialData, errors: {}, submitting: false };

    default: {
      // EXHAUSTIVENESS: if you add a case to the union and forget to handle it,
      // 'action' stops being 'never' and this line fails to compile.
      const unhandled: never = action;
      throw new Error(`Unhandled action: ${JSON.stringify(unhandled)}`);
    }
  }
}

The never trick deserves an explanation, because it's the most useful pattern in this lesson. Inside the default, TypeScript has ruled out every member of the union already handled in the preceding cases. If they're all covered, what's left is never, and the assignment const unhandled: never = action compiles. If you add { type: 'fieldTouched'; field: keyof NewBooking } to the union and don't write its case, that member is left unhandled in the default, it isn't assignable to never, and compilation fails, pointing straight at the reducer.

In other words: the type system forces you to keep the reducer complete. No unit test does that unless someone writes it.

And in the component:

const [state, dispatch] = useReducer(formReducer, initialState);

dispatch({ type: 'hoursChanged', hours: 3 });       // ✅
dispatch({ type: 'hoursChanged', hours: '3' });     // ❌ string is not number
dispatch({ type: 'hourChanged', hours: 3 });        // ❌ nonexistent type
dispatch({ type: 'submitFailed' });                 // ❌ missing 'errors'

All three errors are caught in the editor. In JavaScript, all three would pass silently and produce a corrupted state.

  1. useContext and custom hooks

The classic problem with typed context: the default value. Setting createContext<Session | null>(null) forces every consumer to check for null, even though the provider is always present.

The solution is an access hook that narrows the type and fails loudly if the provider is missing:

// src/contexts/SessionContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import type { User } from '../types/domain';

type SessionContextValue = {
  user: User | null;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => void;
};

// No real default value: undefined marks "no provider".
const SessionContext = createContext<SessionContextValue | undefined>(undefined);

export function SessionProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  async function signIn(email: string, password: string) {
    const response = await fetch('/api/acceso', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    setUser(await response.json());
  }

  function signOut() {
    setUser(null);
  }

  return (
    <SessionContext.Provider value={{ user, signIn, signOut }}>
      {children}
    </SessionContext.Provider>
  );
}

// The access hook: narrows the type and gives a clear error.
export function useSession(): SessionContextValue {
  const value = useContext(SessionContext);
  if (value === undefined) {
    throw new Error('useSession must be used inside <SessionProvider>');
  }
  return value;   // it's no longer undefined here
}

Double benefit: consumers write const { user } = useSession() without checking anything, and whoever forgets the provider gets an explicit message instead of a Cannot read properties of undefined. This pattern was already recommended in 05-04; with types, it's now verified too.

Custom hooks that return tuples need as const:

// src/hooks/useToggle.ts
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const turnOn = useCallback(() => setValue(true), []);
  const turnOff = useCallback(() => setValue(false), []);

  // Without 'as const': (boolean | (() => void))[]  ← unusable
  // With 'as const': readonly [boolean, () => void, () => void, () => void]
  return [value, toggle, turnOn, turnOff] as const;
}

Without as const, TypeScript infers an array whose elements are the union of every type involved, and destructuring const [open, toggle] = useToggle() would give open the type boolean | (() => void): unusable. With as const it infers a tuple with the exact position and type.

For more than three values, returning an object is usually more readable and doesn't need the trick:

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = window.localStorage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage: the generic is inferred from the initial value.
const [favorites, setFavorites] = useLocalStorage<string[]>('favorites', []);

  1. Typing events

React's event types are generic over the element that originates them.

Event Type Typical use
onChange on <input> ChangeEvent<HTMLInputElement> Controlled forms
onChange on <select> ChangeEvent<HTMLSelectElement> TypeSelector
onSubmit on <form> FormEvent<HTMLFormElement> Submission
onClick on <button> MouseEvent<HTMLButtonElement> Buttons
onKeyDown KeyboardEvent<HTMLInputElement> useKeyEvent
onFocus / onBlur FocusEvent<HTMLInputElement> Validation on blur
import { useState, type ChangeEvent, type FormEvent } from 'react';
import type { BikeType } from '../types/domain';

function BikeSearch({ onSearch }: { onSearch: (text: string, type: string) => void }) {
  const [text, setText] = useState('');
  const [type, setType] = useState<BikeType | 'todos'>('todos');

  function handleTextChange(event: ChangeEvent<HTMLInputElement>) {
    setText(event.target.value);   // string, guaranteed
  }

  function handleTypeChange(event: ChangeEvent<HTMLSelectElement>) {
    setType(event.target.value as BikeType | 'todos');
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    onSearch(text, type);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={handleTextChange} />
      <select value={type} onChange={handleTypeChange}>
        <option value="todos">All</option>
        <option value="urbana">Urban</option>
      </select>
    </form>
  );
}

How to find the type without memorizing it, which is what you actually need. Write the handler inline and let TypeScript infer it; then hover over the parameter and the editor will tell you the exact type:

<input onChange={(event) => { /* hover over 'event' */ }} />
// Tooltip: (parameter) event: React.ChangeEvent<HTMLInputElement>

You copy that type over to the named function. That's the real workflow: nobody memorizes these names.

The as in handleTypeChange deserves a comment: event.target.value is always string, because the DOM doesn't know about our union. Here the assertion is acceptable because we control the <option>s ourselves; in a stricter case we'd use the isBikeType guard from section 8.

  1. Redux Toolkit and TanStack Query

Redux Toolkit is designed for TypeScript and only asks for two derived types and two typed hooks:

// src/store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import sessionSlice from '../features/sessionSlice';
import catalogueSlice from '../features/catalogueSlice';
import bookingsSlice from '../features/bookingsSlice';

export const store = configureStore({
  reducer: {
    session: sessionSlice,
    catalogue: catalogueSlice,
    bookings: bookingsSlice,
  },
});

// DERIVED from the store: never written by hand.
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// src/store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

From here on, all the global state is typed with no extra effort:

import { useAppSelector, useAppDispatch } from '../store/hooks';
import { typeFilterChanged } from '../features/catalogueSlice';

function TypeSelector() {
  // 'state' is typed: autocompletes session, catalogue, and bookings.
  const filter = useAppSelector((state) => state.catalogue.typeFilter);
  const dispatch = useAppDispatch();

  return (
    <select value={filter} onChange={(e) => dispatch(typeFilterChanged(e.target.value))}>
      {/* ... */}
    </select>
  );
}

And the slice gets typed by annotating the initial state:

// src/features/catalogueSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { BikeType } from '../types/domain';

type CatalogueState = {
  typeFilter: BikeType | 'todos';
  search: string;
};

const initialState: CatalogueState = { typeFilter: 'todos', search: '' };

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState,
  reducers: {
    typeFilterChanged(state, action: PayloadAction<BikeType | 'todos'>) {
      state.typeFilter = action.payload;
    },
    searchChanged(state, action: PayloadAction<string>) {
      state.search = action.payload;
    },
  },
});

export const { typeFilterChanged, searchChanged } = catalogueSlice.actions;
export default catalogueSlice.reducer;

PayloadAction<T> is the only thing you need to annotate: the action creators and their types are generated on their own.

TanStack Query infers the type of data from the return type of queryFn:

// src/queries/bikes.ts
import { useQuery } from '@tanstack/react-query';
import type { Bike } from '../types/domain';

async function fetchBikes(): Promise<Bike[]> {
  const response = await fetch('http://localhost:3001/bicicletas');
  if (!response.ok) throw new Error('Could not load the catalogue.');
  return response.json();   // ← here's the lie: see section 17
}

export function useBikes() {
  return useQuery({
    queryKey: ['bikes'],
    queryFn: fetchBikes,
  });
}

In the component, data is Bike[] | undefinedundefined while it's loading — and TypeScript forces you to handle that case. That's exactly the check that used to get forgotten in JavaScript and produced Cannot read properties of undefined (reading 'map').

  1. External data: validating at the boundary with Zod

And now the most important point in the lesson, the one that separates someone who uses TypeScript from someone who understands it.

An API response isn't typed. response.json() returns any. Writing Promise<Bike[]> in the signature checks nothing: it's a promise you make to the compiler, not a verification.

If json-server returns pricePerHour as a string, or the field gets renamed on the server, TypeScript won't say a word, and the failure will show up far away, in the component that does pricePerHour.toFixed(2).

The solution is to validate at the boundary: check the shape of the data once, at the point where it enters the application, and trust the types from then on.

npm install zod
// src/types/schemas.ts
import { z } from 'zod';

export const bikeTypeSchema = z.enum(['urbana', 'electrica', 'carga']);
export const bikeStatusSchema = z.enum([
  'disponible', 'alquilada', 'mantenimiento',
]);

export const bikeSchema = z.object({
  id: z.string(),
  model: z.string().min(1),
  type: bikeTypeSchema,
  status: bikeStatusSchema,
  stationId: z.string(),
  pricePerHour: z.number().positive(),
});

export const bikeListSchema = z.array(bikeSchema);

// THE TYPE IS DERIVED FROM THE SCHEMA: a single source of truth.
export type Bike = z.infer<typeof bikeSchema>;
export type BikeType = z.infer<typeof bikeTypeSchema>;
export type BikeStatus = z.infer<typeof bikeStatusSchema>;

z.infer is the key piece: the type is derived from the schema, not written separately. They can't drift apart.

And the query starts validating for real:

// src/queries/bikes.ts
import { useQuery } from '@tanstack/react-query';
import { bikeListSchema } from '../types/schemas';

async function fetchBikes() {
  const response = await fetch('http://localhost:3001/bicicletas');
  if (!response.ok) throw new Error('Could not load the catalogue.');

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

  // Validates at RUNTIME and returns a typed value. If it doesn't fit, it throws.
  return bikeListSchema.parse(data);
}

Comparing the two approaches:

as Bike[] or Promise<Bike[]> schema.parse(data)
Compile-time check Yes Yes
Runtime check No Yes
If the API changes Silent failure, far from the source Immediate error with the exact field
Cost Zero A few microseconds per response

When validation fails, the message pinpoints the problem precisely:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [1, "pricePerHour"],
    "message": "Expected number, received string"
  }
]

"Element 1 has pricePerHour as a string instead of a number." That's diagnosis, not guesswork.

Where it makes sense to apply this validation in CicloUrbano:

Boundary Validate? Reason
API responses Yes The server can change without warning
localStorage (useLocalStorage) Yes It may hold data from an earlier version
URL parameters (?tipo=) Yes The user types them
import.meta.env Yes, on startup A missing variable → an immediate, clear failure
Props between your own components No TypeScript already checks them
Internal state No It never leaves the program

Zod is also useful, with the same schema, for validating forms: module 3's validateBooking can be rewritten as a schema and share its rules between the client and the server — including the server actions from 10-03.

  1. TypeScript and tests: what each one catches

Back to the pyramid, now comparing the two layers.

Situation Does TypeScript catch it? Do the tests catch it?
Misspelled prop (bike instead of bicycle) ✅ As you type ⚠️ Only if there's a test for that component
A field renamed in the domain ✅ In all 12 usages ⚠️ Only where there's coverage
A new, unhandled case in the reducer ✅ With the never trick ❌ Unless there's a specific test
Forgetting to check undefined ✅ With strict ⚠️ Only if the test exercises that case
The price calculation is wrong ✅ Unit test
The button isn't disabled during maintenance ✅ Testing Library
The booking never reaches the API ✅ E2E test
The error text is incomprehensible ✅ Integration test
The API returns a different data shape ❌ (yes, with Zod) ✅ If there's a contract in MSW
An infinite loop in useEffect ✅ The test hangs

The conclusion is right there in the two columns: TypeScript checks the shape; tests check the behavior. Substituting one for the other doesn't work in either direction.

What TypeScript does do is eliminate an entire category of trivial tests. You no longer need to check "that the component doesn't blow up if bikes is undefined": with strict, that doesn't compile. That time gets reinvested in testing behavior, which is what matters.

And tests get typed too. With .test.tsx, Testing Library and MSW gain type checking:

// src/tests/BikeCard.test.tsx
import { render, screen } from '@testing-library/react';
import BikeCard from '../components/BikeCard';
import type { Bike } from '../types/domain';

const BIKE: Bike = {
  id: 'bici-002',
  model: 'Electric Pro',
  type: 'electrica',
  status: 'alquilada',
  stationId: 'est-01',
  pricePerHour: 4.0,
};

test('shows the bike model and status', () => {
  render(<BikeCard bike={BIKE} />);
  expect(screen.getByText('Electric Pro')).toBeInTheDocument();
  expect(screen.getByText('Rented')).toBeInTheDocument();
});

Concrete benefit: if Bike gains a required field, the test fixture stops compiling. Stale mock data — a classic plague in large suites — becomes impossible.

  1. How to read a long error message

TypeScript errors are intimidating because of their length. The technique for reading them is always the same.

Type '{ bike: { id: string; model: string; type: string; status: string;
stationId: string; pricePerHour: number; }; }' is not assignable to type
'IntrinsicAttributes & Props'.
  Types of property 'bike' are not assignable.
    Type '{ id: string; model: string; type: string; status: string; ... }' is
    not assignable to type 'Bike'.
      Types of property 'type' are not assignable.
        Type 'string' is not assignable to type 'BikeType'.

Read it from the bottom up. The last line is the cause; the ones above are the path that leads to it.

  • Last line: 'string' is not assignable to 'BikeType'. That's where the real problem is.
  • Second-to-last: it happens on the type property.
  • The ones before that: inside the object passed to the bike prop.

Diagnosis: somewhere a bike was created whose type is string instead of the union. Common cause: an unannotated object literal, or data from the API that wasn't validated.

// Source of the problem
const bike = { id: 'bici-002', type: 'electrica', /* ... */ };
// 'type' is inferred as 'string'

// Solution A: annotate the object
const bike: Bike = { id: 'bici-002', type: 'electrica', /* ... */ };

// Solution B: as const on the value
const bike = { id: 'bici-002', type: 'electrica' as const, /* ... */ };

Common beginner errors, with what they actually mean:

Message What it actually means
Object is possibly 'undefined' You need to check before using it: ?. or an if
Property 'x' does not exist on type 'y' Misspelled name, or the type is narrower than you thought
Type 'string' is not assignable to type '"a" | "b"' string was used where a literal union belongs
Argument of type 'X' is not assignable to parameter of type 'Y' Argument with the wrong shape; compare field by field
Cannot find module './X' or its type declarations Missing extension, or the file hasn't been migrated yet
Type 'never[]' is not assignable... useState([]) without a generic
JSX element type does not have any construct signatures A non-component value was used as a component

Common Mistakes and Tips

  • Starting without strict. Without strictNullChecks, TypeScript misses most of the real bugs. Turn it on from day one; adding it to a large project later is painful.
  • Using any to get past a problem. It voids checks in cascade. If you don't know the type, use unknown and narrow it.
  • Trusting as with API data. It checks nothing at runtime. Validate at the boundary with Zod and derive the type from the schema.
  • Believing Vite checks types. It doesn't. tsc --noEmit in the build and in CI, or typing is purely decorative.
  • Writing Bike and bikeSchema separately. They drift apart. z.infer derives one from the other.
  • Writing RootState by hand. It's derived with ReturnType<typeof store.getState>. Written by hand, it goes stale the moment you add a slice.
  • Forgetting as const in a hook that returns a tuple. The type becomes an array of unions and destructuring stops working.
  • Using React.FC. An outdated convention: adds nothing and gets in the way of generics.
  • Renaming a file with JSX to .ts. It must be .tsx, or the compiler reads <Component> as a type assertion and produces baffling errors.
  • Migrating from the root toward the leaves. The other way around: domain types first, then utilities and hooks, then components. Otherwise everything you import arrives as any.
  • Tip: hover over things. The editor tells you the inferred type of any expression. It's faster and more reliable than searching the docs.
  • Tip: turn on noUncheckedIndexedAccess. It's annoying for the first two weeks and prevents an entire family of production errors.

Exercises

Exercise 1. Type this CicloUrbano component, which is currently in JavaScript. Define the props type, use the domain types, and fix the two type problems that will show up when you do.

// src/components/BookingPanel.jsx
function BookingPanel({ bike, hours, onConfirm, discount }) {
  const total = bike.pricePerHour * hours * (1 - discount);
  const canBook = bike.status === 'disponible';

  return (
    <aside>
      <p>Estimated total: {total.toFixed(2)} €</p>
      <button onClick={() => onConfirm(bike.id, hours)} disabled={!canBook}>
        Book {hours} h
      </button>
    </aside>
  );
}

Exercise 2. Write the typed reducer for BookingsPanel, which manages the user's list of bookings. It must support: loading the list, marking a booking as cancelled, filtering by status, and recording an error. Define BookingsState and the discriminated union BookingsAction, include the exhaustiveness never case, and demonstrate with an example what happens when you add a new action without handling it.

Exercise 3. CicloUrbano's API has started returning docks as a string on some stations, and district is sometimes missing. Write the Zod schema for Station that: validates the fields, converts docks to a number even when it arrives as a string, gives district the value 'Unassigned' when it's absent, and derives the Station type. Also write the query function that uses it, and explain exactly what happens when invalid data comes in.

Solutions

Solution 1.

// src/components/BookingPanel.tsx
import type { Bike } from '../types/domain';

type Props = {
  bike: Bike;
  hours: number;
  onConfirm: (bicicletaId: string, hours: number) => void;
  discount?: number;   // optional: not every booking has a discount
};

function BookingPanel({ bike, hours, onConfirm, discount = 0 }: Props) {
  const total = bike.pricePerHour * hours * (1 - discount);
  const canBook = bike.status === 'disponible';

  return (
    <aside>
      <p>Estimated total: {total.toFixed(2)} €</p>
      <button
        type="button"
        onClick={() => onConfirm(bike.id, hours)}
        disabled={!canBook}
      >
        Book {hours} h
      </button>
    </aside>
  );
}

export default BookingPanel;

The two problems that surface when typing this:

  1. discount might not be passed. In JavaScript, omitting it made 1 - undefined = NaN and the total displayed as "NaN €", a silent failure. When typing it, you either declare it required or give it a default value. Here the right call is discount = 0.
  2. onConfirm needs an explicit signature. Without one it would be Function, and any call with the wrong arguments would compile. With (bicicletaId: string, hours: number) => void, swapping the argument order becomes a compile error.

As a bonus, bike.status === 'disponible' is now checked against the union: writing 'Disponible' would error out, because it isn't a valid value of BikeStatus.

Solution 2.

// src/reducers/bookings.ts
import type { Booking, BookingStatus } from '../types/domain';

export type BookingsState = {
  bookings: Booking[];
  filter: BookingStatus | 'todas';
  loading: boolean;
  error: string | null;
};

export type BookingsAction =
  | { type: 'loadStarted' }
  | { type: 'loadCompleted'; bookings: Booking[] }
  | { type: 'loadFailed'; message: string }
  | { type: 'bookingCancelled'; reservaId: string }
  | { type: 'filterChanged'; filter: BookingStatus | 'todas' };

export const initialState: BookingsState = {
  bookings: [],
  filter: 'todas',
  loading: false,
  error: null,
};

export function bookingsReducer(
  state: BookingsState,
  action: BookingsAction
): BookingsState {
  switch (action.type) {
    case 'loadStarted':
      return { ...state, loading: true, error: null };

    case 'loadCompleted':
      return { ...state, loading: false, bookings: action.bookings };

    case 'loadFailed':
      return { ...state, loading: false, error: action.message };

    case 'bookingCancelled':
      return {
        ...state,
        bookings: state.bookings.map((booking) =>
          booking.id === action.reservaId
            ? { ...booking, status: 'cancelada' }
            : booking
        ),
      };

    case 'filterChanged':
      return { ...state, filter: action.filter };

    default: {
      const unhandled: never = action;
      throw new Error(`Unhandled action: ${JSON.stringify(unhandled)}`);
    }
  }
}

What happens when you add an action without handling it. If you extend the union:

export type BookingsAction =
  | /* ... the five from before ... */
  | { type: 'bookingExtended'; reservaId: string; extraHours: number };

and you don't write its case, in the default the type of action is no longer narrowed to never, but to { type: 'bookingExtended'; ... }, and compilation fails:

Type '{ type: "bookingExtended"; reservaId: string; extraHours: number; }'
is not assignable to type 'never'.

The error points at the exact line in the reducer. It's a free completeness check that no test provides unless you write one explicitly. Also notice { ...booking, status: 'cancelada' }: if you wrote 'cancelado', TypeScript would reject it for not belonging to BookingStatus.

Solution 3.

// src/types/schemas.ts
import { z } from 'zod';

export const stationSchema = z.object({
  id: z.string(),
  name: z.string().min(1),

  // district may be missing: give it a default value.
  district: z.string().default('Unassigned'),

  // docks may arrive as a number or as a numeric string.
  docks: z.union([
    z.number().int().nonnegative(),
    z.string().regex(/^\d+$/).transform(Number),
  ]),
});

export const stationListSchema = z.array(stationSchema);

// The derived type already has docks: number and district: string (not optional).
export type Station = z.infer<typeof stationSchema>;
// src/queries/stations.ts
import { useQuery } from '@tanstack/react-query';
import { stationListSchema } from '../types/schemas';

async function fetchStations() {
  const response = await fetch('http://localhost:3001/estaciones');
  if (!response.ok) throw new Error('Could not load the stations.');

  const data: unknown = await response.json();
  return stationListSchema.parse(data);
}

export function useStations() {
  return useQuery({ queryKey: ['stations'], queryFn: fetchStations });
}

What happens when invalid data comes in — for example, docks: "twenty":

  1. parse throws a ZodError with the exact path: [1, "docks"], i.e., the second station.
  2. Since it's thrown inside queryFn, TanStack Query treats it as a query error: isError becomes true and data stays undefined.
  3. The component shows its error UI — or the ErrorBoundary if useSuspenseQuery is used, per 10-03 — instead of rendering NaN.
  4. The error gets logged with the offending field, so the diagnosis is immediate.

Two nuances about this schema's design. default('Unassigned') makes the derived type have district: string not optional, so components don't need to check for undefined: the uncertainty has been resolved at the boundary, which is exactly the goal. And if you'd rather tolerate partial errors instead of failing the whole query, safeParse returns { success, data, error } without throwing, which lets you discard the invalid stations and show the rest.

Conclusion

This lesson has completed the base of the pyramid that 09-01 left half-finished. The linter covered style and shape errors in the code; TypeScript covers the contracts, and it does so at the cheapest possible moment: while you type, with the cursor on the line of the error.

From the setup, the essential point is that TypeScript enters an existing project without rewriting it: typescript plus @types/react and @types/react-dom, a tsconfig.json with strict: true — non-negotiable —, jsx: "react-jsx", moduleResolution: "bundler", allowJs: true for coexistence, and noUncheckedIndexedAccess so indexed access tells the truth. And one warning that decides whether any of this is worth anything: Vite doesn't check types, so tsc --noEmit has to be in the build and in continuous integration. The migration goes from the leaves to the root — domain, utilities, hooks, leaf components, composite components, global state, pages — in small branches, with module 9's tests acting as a safety net.

From the language, the subset used every day is short: primitives with inference, literal unions — the domain's most cost-effective tool —, optionals, arrays, basic generics, interface for entities and type for everything else, and unknown instead of any with x is T type guards to actually narrow. src/types/domain.ts is now fixed, with Bike, Station, User, Booking, BikeType, BikeStatus, UserRole, and BookingStatus, plus the derived types NewBooking with Omit and BookingErrors with Partial<Record<...>>, which make extending an entity propagate the change to everything that depends on it.

In React, the pieces fall into place like this: props with type and default values in the destructuring; children with ReactNode; inherited DOM attributes with ComponentProps<'button'>; generic components like List<T> that preserve the element's type; and no React.FC. In hooks, useState infers except when it starts at null or []; useRef<HTMLInputElement>(null) for the DOM and useRef<number>(0) for mutable values; useContext with no default value plus an access hook that narrows the type and fails loudly if the provider is missing; and as const in custom hooks that return tuples. The case where TypeScript truly shines is useReducer with actions as a discriminated union: inside each case the exact fields are known, and the never trick in the default turns "I forgot to handle an action" into a compile error. Events aren't memorized: you write them inline, read the type the editor shows, and copy it.

With the libraries, Redux Toolkit only asks you to derive RootState and AppDispatch from the store and create typed useAppSelector/useAppDispatch; TanStack Query infers data from queryFn and forces you to handle the loading undefined. And the point that changes the way you work the most: an API response isn't typed, so you validate at the boundary with Zod and derive the type from the schema with z.infer, in a single source of truth. Everything that comes in from outside — API, localStorage, URL parameters, environment variables — goes through that filter; whatever flows between your own components is already checked by TypeScript.

And the final balance, which answers the implicit question from 09-01: TypeScript checks the shape; tests check the behavior. Types catch the misspelled prop, the field renamed across its twelve usages, and the missing case; they don't catch a wrong price calculation, a button that isn't disabled during maintenance, or an incomprehensible error message. What they do is eliminate an entire category of trivial tests, freeing up that time to test what actually matters.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved