← React & TypeScript

TypeScript Fundamentals

What is a union type and what is narrowing in TypeScript?

Show answer — try answering out loud first

A union type says a value can be one of several types, for example string | number. Narrowing is the process by which TypeScript "narrows" that union to a concrete type within a block, using checks like typeof, in, or comparisons.

With a union, TypeScript only lets you use what's safe for all members. To use methods specific to one of them, you first have to prove with code which one it is. That's narrowing.

Typical ways to narrow:

  • typeof valor === "string": for primitives.
  • "propiedad" in objeto: to distinguish objects by their properties.
  • Comparisons with literals: if (estado === "error").
  • Discriminated unions: each object in the union carries a common literal field (the discriminant, typically tipo or kind). A switch on that field automatically narrows to the correct type.

Bonus of discriminated unions: in the switch's default you can assign the value to never. If tomorrow someone adds a case to the union and forgets to handle it, the compiler warns you. This is called an exhaustiveness check.

// Simple union + narrowing with typeof
function formatear(valor: string | number): string {
  if (typeof valor === "string") {
    return valor.toUpperCase(); // here it's a string
  }
  return valor.toFixed(2);      // here it can only be a number
}

// Discriminated union: the "tipo" field is the discriminant
type Circulo = { tipo: "circulo"; radio: number };
type Cuadrado = { tipo: "cuadrado"; lado: number };
type Figura = Circulo | Cuadrado;

function area(figura: Figura): number {
  switch (figura.tipo) {
    case "circulo":
      return Math.PI * figura.radio ** 2; // TypeScript knows it's a Circulo
    case "cuadrado":
      return figura.lado ** 2;            // here it's a Cuadrado
    default: {
      // Exhaustiveness: if a shape is added and we don't handle it, this won't compile
      const nunca: never = figura;
      return nunca;
    }
  }
}

// Narrowing with "in" for objects without a discriminant
type Perro = { ladrar: () => void };
type Gato = { maullar: () => void };

function hacerSonido(animal: Perro | Gato): void {
  if ("ladrar" in animal) animal.ladrar();
  else animal.maullar();
}

Using a specific method without narrowing first:

function gritar(valor: string | number) {
  // return valor.toUpperCase();
  // Error: Property 'toUpperCase' does not exist on type 'string | number'.

  // Fix: narrow first
  if (typeof valor === "string") return valor.toUpperCase();
  return String(valor);
}

The junior often "solves it" with as string or any. That silences the error but can blow up at runtime; narrowing solves it safely.

"A union type expresses that a value can be one of several types, like string | number. TypeScript only lets me use what's common to all of them, so to access the specific parts I do narrowing: I check with typeof, with in, or by comparing literals, and inside that block the compiler already knows the exact type. My favorite pattern is discriminated unions: each variant carries a literal field like tipo, I do a switch on it, and in the default I assign to never so that, if someone adds a new variant, the compiler forces me to handle it."

Quick challenge

Define a discriminated union Respuesta with two variants: { estado: "ok"; datos: string[] } and { estado: "error"; mensaje: string }. Write procesar(respuesta: Respuesta): string that returns how many items there are or the error message, with an exhaustiveness check.

See solution
type Respuesta =
  | { estado: "ok"; datos: string[] }
  | { estado: "error"; mensaje: string };

function procesar(respuesta: Respuesta): string {
  switch (respuesta.estado) {
    case "ok":
      return `Llegaron ${respuesta.datos.length} datos`;
    case "error":
      return `Fallo: ${respuesta.mensaje}`;
    default: {
      // If a new variant is added, this line stops compiling
      const nunca: never = respuesta;
      return nunca;
    }
  }
}