Tipos de unión e intersección

Avanzado
TypeScript
TypeScript
Actualizado: 04/05/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

Uniones con operador |

Los tipos de unión representan uno de los conceptos más potentes y flexibles de TypeScript, permitiéndonos expresar que un valor puede ser de varios tipos diferentes. El operador de unión, representado por el símbolo |, nos permite combinar dos o más tipos en uno solo.

Un tipo de unión describe un valor que puede ser de cualquiera de los tipos especificados. Esto resulta extremadamente útil cuando necesitamos manejar diferentes tipos de datos en una misma variable o parámetro.

Sintaxis básica

La sintaxis para crear un tipo de unión es sencilla:

let variable: TipoA | TipoB;

Esto indica que variable puede contener un valor de tipo TipoA o de tipo TipoB.

Casos de uso comunes

1. Variables que pueden tener diferentes tipos

// Una variable que puede ser string o number
let id: string | number;

id = 101;     // Válido
id = "A101";  // También válido
id = true;    // Error: Type 'boolean' is not assignable to type 'string | number'

2. Parámetros de función flexibles

// Una función que acepta un string o un array de strings
function printId(id: string | string[]): void {
  if (Array.isArray(id)) {
    console.log("ID array: " + id.join(", "));
  } else {
    console.log("ID: " + id);
  }
}

printId("abc123");        // Válido
printId(["abc", "123"]);  // Válido

3. Valores opcionales con null o undefined

// Una variable que puede contener un string o ser null
let userName: string | null = "user1";

// Más tarde en el código
userName = null;  // Válido cuando el usuario cierra sesión

Uniones con tipos literales

Las uniones son especialmente útiles cuando se combinan con tipos literales, permitiendo crear conjuntos específicos de valores permitidos:

// Un tipo que representa estados específicos de un proceso
type Status = "pending" | "processing" | "completed" | "failed";

let currentStatus: Status = "pending";
currentStatus = "completed";  // Válido
currentStatus = "unknown";    // Error: Type '"unknown"' is not assignable to type 'Status'

Este patrón es muy común para representar estados, niveles de acceso, o cualquier conjunto finito de opciones.

Uniones con interfaces y tipos personalizados

También podemos crear uniones con tipos más complejos como interfaces:

interface Employee {
  id: number;
  name: string;
}

interface Customer {
  id: string;
  name: string;
  email: string;
}

// Una función que puede procesar tanto empleados como clientes
function processUser(user: Employee | Customer): void {
  console.log("Nombre: " + user.name);
  
  // Acceso a propiedades específicas requiere verificación
  if ("email" in user) {
    console.log("Email: " + user.email);
  }
}

Accediendo a propiedades en uniones

Al trabajar con uniones de tipos complejos, solo podemos acceder directamente a las propiedades comunes a todos los tipos de la unión sin realizar comprobaciones:

interface Car {
  make: string;
  model: string;
  year: number;
  startEngine(): void;
}

interface Bicycle {
  make: string;
  model: string;
  type: "mountain" | "road" | "hybrid";
  startPedaling(): void;
}

function describeVehicle(vehicle: Car | Bicycle) {
  // Propiedades comunes - acceso directo
  console.log(`${vehicle.make} ${vehicle.model}`);
  
  // Propiedades específicas - requieren verificación
  if ("year" in vehicle) {
    console.log(`Año: ${vehicle.year}`);
    vehicle.startEngine();
  } else {
    console.log(`Tipo: ${vehicle.type}`);
    vehicle.startPedaling();
  }
}

Uniones con tipos genéricos

Las uniones también pueden combinarse con tipos genéricos para crear estructuras de datos flexibles:

// Un resultado que puede contener datos o un error
type Result<T> = { success: true; data: T } | { success: false; error: string };

function fetchUserData(userId: string): Result<{ name: string; email: string }> {
  // Simulación de una petición
  if (userId === "valid") {
    return {
      success: true,
      data: { name: "John Doe", email: "john@example.com" }
    };
  } else {
    return {
      success: false,
      error: "Usuario no encontrado"
    };
  }
}

const result = fetchUserData("valid");
if (result.success) {
  // TypeScript sabe que result.data existe aquí
  console.log(result.data.name);
} else {
  // TypeScript sabe que result.error existe aquí
  console.log(result.error);
}

Limitaciones de las uniones

Aunque las uniones son muy útiles, tienen algunas limitaciones:

  1. Acceso a propiedades específicas: Como vimos anteriormente, solo podemos acceder directamente a propiedades comunes a todos los tipos de la unión.

  2. Operaciones específicas: No podemos realizar operaciones específicas de un tipo sin antes verificar el tipo.

function calculateLength(value: string | string[]): number {
  // Esto generaría un error porque .length tiene diferentes significados
  // en string y string[]
  // return value.length; // Error
  
  // Necesitamos verificar el tipo primero
  if (typeof value === "string") {
    return value.length;
  } else {
    return value.reduce((acc, str) => acc + str.length, 0);
  }
}

Uniones vs. any

Es importante distinguir entre usar uniones y usar el tipo any:

// Con unión - TypeScript verifica que solo se asignen los tipos permitidos
let id: string | number;
id = 123;      // OK
id = "abc";    // OK
id = true;     // Error

// Con any - TypeScript no realiza verificaciones
let idAny: any;
idAny = 123;   // OK
idAny = "abc"; // OK
idAny = true;  // OK - pero podría causar errores en tiempo de ejecución

Las uniones proporcionan flexibilidad con seguridad de tipos, mientras que any elimina completamente la verificación de tipos.

¿Te está gustando esta lección?

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Intersecciones con operador &

Las intersecciones de tipos representan una característica fundamental en TypeScript que complementa a las uniones. Mientras que las uniones (con el operador |) permiten que un valor sea de uno u otro tipo, las intersecciones (con el operador &) combinan múltiples tipos en uno solo que contiene todas las propiedades de los tipos combinados.

Un tipo de intersección crea un nuevo tipo que tiene todas las características de cada uno de los tipos que lo componen. Esto resulta especialmente útil cuando necesitamos combinar interfaces o tipos existentes.

Sintaxis básica

La sintaxis para crear un tipo de intersección es directa:

type CombinedType = TypeA & TypeB;

Esto indica que CombinedType tendrá todas las propiedades y métodos tanto de TypeA como de TypeB.

Casos de uso comunes

1. Combinación de interfaces

interface Identificable {
  id: string;
  label: string;
}

interface Validable {
  isValid(): boolean;
  validate(): void;
}

// Combinamos ambas interfaces
type ValidatableItem = Identificable & Validable;

// Un objeto debe implementar todas las propiedades de ambas interfaces
const formField: ValidatableItem = {
  id: "email-field",
  label: "Correo electrónico",
  isValid() {
    return this.id.length > 0;
  },
  validate() {
    console.log(`Validando ${this.label}...`);
  }
};

2. Extensión de tipos existentes

type BaseAddress = {
  street: string;
  city: string;
};

type AddressWithRegion = BaseAddress & {
  region: string;
};

type InternationalAddress = AddressWithRegion & {
  country: string;
  postalCode: string;
};

const address: InternationalAddress = {
  street: "Calle Mayor 123",
  city: "Madrid",
  region: "Comunidad de Madrid",
  country: "España",
  postalCode: "28001"
};

3. Mixins de comportamiento

Las intersecciones son ideales para implementar el patrón de mixins, donde combinamos múltiples comportamientos:

type Loggable = {
  log(message: string): void;
};

type Serializable = {
  serialize(): string;
};

type Persistable = {
  save(): void;
  load(): void;
};

// Creamos un tipo que combina todos estos comportamientos
type DataObject = Loggable & Serializable & Persistable;

class ConfigurationManager implements DataObject {
  private data: Record<string, any> = {};
  
  log(message: string): void {
    console.log(`[ConfigManager] ${message}`);
  }
  
  serialize(): string {
    return JSON.stringify(this.data);
  }
  
  save(): void {
    const data = this.serialize();
    this.log(`Guardando datos: ${data}`);
    localStorage.setItem('config', data);
  }
  
  load(): void {
    const stored = localStorage.getItem('config');
    if (stored) {
      this.data = JSON.parse(stored);
      this.log('Configuración cargada');
    }
  }
}

Intersecciones con tipos primitivos

Cuando intersecamos tipos primitivos, TypeScript calcula el tipo común más específico:

type NumberAndString = number & string;

En este caso, NumberAndString es en realidad el tipo never, porque no existe ningún valor que pueda ser simultáneamente un número y una cadena.

Sin embargo, las intersecciones son útiles con tipos literales:

type PositiveEvenNumber = number & { __brand: "PositiveEven" };

function createPositiveEven(n: number): PositiveEvenNumber {
  if (n <= 0 || n % 2 !== 0) {
    throw new Error("El número debe ser positivo y par");
  }
  return n as PositiveEvenNumber;
}

// Uso del tipo con "branded types"
const evenNumber = createPositiveEven(4);

Intersecciones vs. herencia

Las intersecciones proporcionan una alternativa a la herencia tradicional en la programación orientada a objetos:

// Enfoque de herencia
class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Swimmer extends Animal {
  swim() {
    console.log(`${this.name} está nadando`);
  }
}

// Enfoque de intersección
type Animal = {
  name: string;
};

type Swimmer = {
  swim(): void;
};

type Fish = Animal & Swimmer;

const nemo: Fish = {
  name: "Nemo",
  swim() {
    console.log(`${this.name} está nadando`);
  }
};

La ventaja del enfoque de intersección es que permite combinar tipos de múltiples fuentes sin necesidad de una jerarquía de clases.

Resolución de conflictos en propiedades

Cuando dos tipos en una intersección tienen propiedades con el mismo nombre pero tipos diferentes, TypeScript crea una intersección de esos tipos de propiedades:

type A = { prop: number };
type B = { prop: string };

type AB = A & B;

// Esto no funcionará porque no existe un valor que sea
// simultáneamente number y string
const ab: AB = {
  prop: 5 // Error: Type 'number' is not assignable to type 'never'
};

En este caso, prop en el tipo AB es de tipo number & string, que se reduce a never.

Intersecciones con genéricos

Las intersecciones son especialmente potentes cuando se combinan con genéricos:

type WithId<T> = T & { id: string };

interface User {
  name: string;
  email: string;
}

// Ahora podemos crear usuarios con ID garantizado
const user: WithId<User> = {
  id: "user-123",
  name: "Ana García",
  email: "ana@example.com"
};

// Funciona con cualquier tipo
interface Product {
  title: string;
  price: number;
}

const product: WithId<Product> = {
  id: "prod-456",
  title: "Teclado mecánico",
  price: 89.99
};

Uso práctico: patrón de extensión de configuración

Las intersecciones son ideales para implementar patrones de configuración extensible:

// Configuración base con valores por defecto
interface BaseConfig {
  endpoint: string;
  timeout: number;
  retries: number;
}

// Configuraciones específicas para diferentes entornos
interface DevConfig {
  mockResponses: boolean;
  logLevel: 'debug' | 'info';
}

interface ProdConfig {
  sslEnabled: boolean;
  cacheTime: number;
}

// Configuraciones combinadas
type DevelopmentConfig = BaseConfig & DevConfig;
type ProductionConfig = BaseConfig & ProdConfig;

function createApiClient(config: DevelopmentConfig | ProductionConfig) {
  // Implementación del cliente...
}

// Uso
const devConfig: DevelopmentConfig = {
  endpoint: 'http://dev-api.example.com',
  timeout: 5000,
  retries: 3,
  mockResponses: true,
  logLevel: 'debug'
};

createApiClient(devConfig);

Intersecciones vs. uniones discriminadas

Es importante entender la diferencia entre intersecciones y uniones discriminadas:

// Intersección: un objeto que es AMBOS tipos a la vez
type ButtonProps = CommonProps & (PrimaryButtonProps | SecondaryButtonProps);

// Unión discriminada: un objeto que es UNO U OTRO tipo
type ButtonVariant = 
  | { variant: 'primary'; color: string }
  | { variant: 'secondary'; outline: boolean };

Las intersecciones son útiles cuando queremos combinar características, mientras que las uniones discriminadas son mejores cuando queremos representar alternativas.

Estrechamiento de tipos (type narrowing)

El estrechamiento de tipos (type narrowing) es una técnica fundamental en TypeScript que permite refinar el tipo de una variable dentro de un bloque de código específico. Cuando trabajamos con tipos de unión, TypeScript necesita mecanismos para determinar qué tipo específico tiene una variable en un momento dado del programa.

Esta técnica resulta esencial cuando manejamos uniones de tipos, ya que nos permite acceder de forma segura a propiedades y métodos específicos de cada tipo después de verificar su naturaleza.

Operador typeof

El operador typeof es la forma más básica de estrechamiento de tipos, permitiéndonos distinguir entre tipos primitivos:

function printValue(value: string | number) {
  if (typeof value === "string") {
    // En este bloque, TypeScript sabe que value es un string
    console.log(value.toUpperCase());
  } else {
    // En este bloque, TypeScript sabe que value es un number
    console.log(value.toFixed(2));
  }
}

TypeScript reconoce este patrón y estrecha el tipo de value dentro de cada rama condicional, permitiéndonos acceder a métodos específicos de cada tipo sin errores de compilación.

Operador instanceof

Para trabajar con clases e instancias de objetos, podemos usar el operador instanceof:

class Customer {
  name: string;
  email: string;
  
  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }
  
  sendEmail() {
    console.log(`Enviando email a ${this.email}`);
  }
}

class Employee {
  name: string;
  department: string;
  
  constructor(name: string, department: string) {
    this.name = name;
    this.department = department;
  }
  
  assignTask() {
    console.log(`Asignando tarea a ${this.name} del departamento ${this.department}`);
  }
}

function processUser(user: Customer | Employee) {
  console.log(`Procesando usuario: ${user.name}`);
  
  if (user instanceof Customer) {
    // TypeScript sabe que user es Customer aquí
    user.sendEmail();
  } else {
    // TypeScript sabe que user es Employee aquí
    user.assignTask();
  }
}

Comprobación de propiedades con in

El operador in nos permite verificar si una propiedad existe en un objeto, lo que resulta útil para distinguir entre diferentes interfaces:

interface Article {
  title: string;
  content: string;
  publishDate: Date;
}

interface Video {
  title: string;
  duration: number;
  resolution: string;
}

function displayMedia(media: Article | Video) {
  console.log(`Título: ${media.title}`);
  
  if ("content" in media) {
    // TypeScript sabe que media es Article
    console.log(`Contenido: ${media.content.substring(0, 100)}...`);
    console.log(`Publicado: ${media.publishDate.toLocaleDateString()}`);
  } else {
    // TypeScript sabe que media es Video
    console.log(`Duración: ${media.duration} segundos`);
    console.log(`Resolución: ${media.resolution}`);
  }
}

Predicados de tipo (type predicates)

Los predicados de tipo son funciones que devuelven un valor booleano y tienen una firma especial que le indica a TypeScript cómo debe estrechar el tipo:

// Definimos un predicado de tipo con la sintaxis "paramName is Type"
function isArticle(media: Article | Video): media is Article {
  return (media as Article).content !== undefined;
}

function displayMediaWithPredicate(media: Article | Video) {
  if (isArticle(media)) {
    // TypeScript sabe que media es Article aquí
    console.log(`Extracto: ${media.content.substring(0, 50)}...`);
  } else {
    // TypeScript sabe que media es Video aquí
    console.log(`Ver video (${media.duration}s): ${media.title}`);
  }
}

Los predicados de tipo son especialmente útiles cuando la lógica de verificación es compleja o se necesita reutilizar en varios lugares.

Aserciones de tipo exhaustivas

Una técnica avanzada es usar el tipo never para asegurarnos de que todos los casos posibles han sido manejados:

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // Esta función nunca debería ejecutarse si hemos manejado todos los casos
      const exhaustiveCheck: never = shape;
      throw new Error(`Tipo de forma no manejado: ${exhaustiveCheck}`);
  }
}

Si añadimos un nuevo tipo a Shape pero olvidamos actualizar la función calculateArea, TypeScript generará un error en tiempo de compilación.

Estrechamiento con asignación

TypeScript también estrecha tipos cuando asignamos valores a variables:

let id: string | number;

// Asignación directa
id = "abc123";
// TypeScript sabe que id es string aquí
console.log(id.toUpperCase());

// Nueva asignación
id = 456;
// TypeScript sabe que id es number aquí
console.log(id.toFixed());

Estrechamiento con operadores lógicos

Los operadores lógicos como && y || también participan en el estrechamiento de tipos:

function processValue(value: string | null | undefined) {
  // El operador && estrecha el tipo eliminando null y undefined
  if (value && value.length > 5) {
    console.log(`Valor largo: ${value}`);
  }
  
  // Usando el operador de coalescencia nula
  const safeValue = value ?? "valor predeterminado";
  // TypeScript sabe que safeValue es string aquí
  console.log(safeValue.toUpperCase());
}

Estrechamiento con expresiones de igualdad

Las comparaciones de igualdad también pueden estrechar tipos:

function printId(id: string | number | null) {
  if (id === null) {
    console.log("No hay ID disponible");
    return;
  }
  
  // TypeScript sabe que id es string | number aquí
  console.log(`ID: ${id}`);
  
  // Podemos seguir estrechando
  if (typeof id === "string") {
    console.log(`ID en mayúsculas: ${id.toUpperCase()}`);
  }
}

Estrechamiento con tipos literales

El estrechamiento funciona especialmente bien con tipos literales:

type Direction = "north" | "south" | "east" | "west";

function move(direction: Direction) {
  switch (direction) {
    case "north":
      console.log("Moviendo hacia el norte");
      break;
    case "south":
      console.log("Moviendo hacia el sur");
      break;
    case "east":
      console.log("Moviendo hacia el este");
      break;
    case "west":
      console.log("Moviendo hacia el oeste");
      break;
  }
}

Estrechamiento con genéricos

El estrechamiento de tipos también funciona con tipos genéricos:

function firstElement<T>(arr: T[]): T | undefined {
  if (arr.length === 0) {
    return undefined;
  }
  
  return arr[0];
}

const result = firstElement([1, 2, 3]);
if (result !== undefined) {
  // TypeScript sabe que result es number aquí
  console.log(result.toFixed(2));
}

Limitaciones del estrechamiento de tipos

Es importante entender algunas limitaciones:

function example(x: string | number) {
  // Esta función auxiliar no conserva el estrechamiento
  const isString = typeof x === "string";
  
  if (isString) {
    // Error: TypeScript no puede estrechar x aquí
    // console.log(x.toUpperCase());
  }
  
  // La forma correcta:
  if (typeof x === "string") {
    console.log(x.toUpperCase());
  }
}

TypeScript solo puede realizar estrechamiento cuando la condición está directamente en el flujo de control, no cuando se almacena en variables intermedias.

Estrechamiento con tipos de usuario

Podemos crear nuestros propios mecanismos de estrechamiento para tipos personalizados:

interface SuccessResponse {
  status: "success";
  data: any;
}

interface ErrorResponse {
  status: "error";
  message: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
  if (response.status === "success") {
    // TypeScript sabe que response es SuccessResponse
    console.log(`Datos recibidos: ${JSON.stringify(response.data)}`);
  } else {
    // TypeScript sabe que response es ErrorResponse
    console.log(`Error: ${response.message}`);
  }
}

El estrechamiento de tipos es una herramienta fundamental que permite escribir código TypeScript más seguro y expresivo, especialmente cuando trabajamos con uniones de tipos complejas.

Discriminated unions

Las discriminated unions (uniones discriminadas) representan un patrón de diseño de tipos en TypeScript que combina uniones de tipos con propiedades discriminadoras para crear estructuras de datos más seguras y expresivas. Este patrón es fundamental para modelar datos que pueden existir en diferentes estados o variantes.

Una unión discriminada se caracteriza por tener una propiedad común (el discriminador) cuyo valor literal permite a TypeScript determinar exactamente qué variante de la unión está siendo utilizada en un momento dado. Esto facilita enormemente el manejo de datos complejos con múltiples formas posibles.

Estructura básica

La estructura de una unión discriminada consta de:

  1. Varios tipos (normalmente interfaces o tipos)
  2. Una propiedad discriminadora común con valores literales diferentes
  3. Propiedades específicas para cada variante
type Circle = {
  kind: "circle";  // Propiedad discriminadora
  radius: number;  // Propiedades específicas
};

type Rectangle = {
  kind: "rectangle";  // Mismo nombre de propiedad, valor diferente
  width: number;      // Propiedades específicas
  height: number;
};

// La unión discriminada
type Shape = Circle | Rectangle;

En este ejemplo, kind es la propiedad discriminadora que permite a TypeScript distinguir entre un círculo y un rectángulo.

Trabajando con uniones discriminadas

La principal ventaja de las uniones discriminadas es que TypeScript puede inferir automáticamente el tipo específico después de comprobar el valor del discriminador:

function calculateArea(shape: Shape): number {
  // Comprobando el discriminador
  if (shape.kind === "circle") {
    // TypeScript sabe que shape es Circle aquí
    return Math.PI * shape.radius ** 2;
  } else {
    // TypeScript sabe que shape es Rectangle aquí
    return shape.width * shape.height;
  }
}

const myCircle: Shape = { kind: "circle", radius: 5 };
console.log(calculateArea(myCircle));  // 78.54...

Uso con switch

Las uniones discriminadas funcionan especialmente bien con declaraciones switch, proporcionando una sintaxis clara y comprobaciones de exhaustividad:

// Ampliamos nuestra unión con un nuevo tipo
type Triangle = {
  kind: "triangle";
  base: number;
  height: number;
};

// Unión discriminada actualizada
type Shape = Circle | Rectangle | Triangle;

function calculateArea(shape: Shape): number {
  // Usando switch con el discriminador
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // Comprobación de exhaustividad
      const _exhaustiveCheck: never = shape;
      throw new Error(`Tipo no soportado: ${_exhaustiveCheck}`);
  }
}

La variable _exhaustiveCheck garantiza que si añadimos un nuevo tipo a Shape pero olvidamos actualizarlo en la función, obtendremos un error de compilación.

Discriminadores y propiedades específicas

Cada variante en una unión discriminada puede tener propiedades completamente diferentes, adaptadas a su propósito específico:

type LoginEvent = {
  type: "login";
  userId: string;
  timestamp: Date;
};

type LogoutEvent = {
  type: "logout";
  userId: string;
  timestamp: Date;
};

type PasswordChangeEvent = {
  type: "passwordChange";
  userId: string;
  timestamp: Date;
  newPasswordStrength: "weak" | "medium" | "strong";
};

type UserEvent = LoginEvent | LogoutEvent | PasswordChangeEvent;

function handleUserEvent(event: UserEvent) {
  // Propiedades comunes accesibles directamente
  console.log(`Evento para usuario ${event.userId} a las ${event.timestamp.toISOString()}`);
  
  // Propiedades específicas requieren comprobación del discriminador
  switch (event.type) {
    case "login":
      console.log("Usuario ha iniciado sesión");
      break;
    case "logout":
      console.log("Usuario ha cerrado sesión");
      break;
    case "passwordChange":
      console.log(`Contraseña cambiada. Fortaleza: ${event.newPasswordStrength}`);
      break;
  }
}

Discriminadores múltiples

Aunque lo más común es usar un solo discriminador, podemos utilizar múltiples propiedades para discriminar entre tipos:

type AdminUser = {
  role: "admin";
  permissions: string[];
  department: string;
};

type RegularUser = {
  role: "user";
  subscription: "free" | "premium";
};

type GuestUser = {
  role: "guest";
  expirationDate: Date;
};

type User = AdminUser | RegularUser | GuestUser;

function getUserInfo(user: User): string {
  // Discriminación basada en la propiedad 'role'
  switch (user.role) {
    case "admin":
      return `Administrador del departamento ${user.department}`;
    case "user":
      return `Usuario con suscripción ${user.subscription}`;
    case "guest":
      const daysLeft = Math.ceil((user.expirationDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
      return `Invitado (expira en ${daysLeft} días)`;
  }
}

Discriminadores con tipos literales

Los discriminadores no tienen que ser strings; pueden ser cualquier tipo literal:

type Success = {
  status: 200;
  data: unknown;
  headers: Record<string, string>;
};

type ClientError = {
  status: 400 | 401 | 404;
  error: string;
};

type ServerError = {
  status: 500;
  error: string;
  stackTrace: string;
};

type ApiResponse = Success | ClientError | ServerError;

function handleResponse(response: ApiResponse) {
  // Usando un número como discriminador
  if (response.status === 200) {
    console.log("Éxito:", response.data);
  } else if (response.status >= 400 && response.status < 500) {
    console.log("Error del cliente:", response.error);
  } else {
    console.log("Error del servidor:", response.error);
    console.log("Stack trace:", response.stackTrace);
  }
}

Uniones discriminadas con genéricos

Podemos combinar uniones discriminadas con genéricos para crear patrones reutilizables:

type Result<T, E = Error> = 
  | { status: "success"; value: T }
  | { status: "error"; error: E };

function fetchUserData(userId: string): Result<{name: string, email: string}> {
  try {
    // Simulación de una petición
    if (userId === "123") {
      return {
        status: "success",
        value: { name: "Ana García", email: "ana@example.com" }
      };
    }
    throw new Error("Usuario no encontrado");
  } catch (error) {
    return {
      status: "error",
      error: error instanceof Error ? error : new Error(String(error))
    };
  }
}

const result = fetchUserData("123");

if (result.status === "success") {
  // TypeScript sabe que result.value existe y es del tipo correcto
  console.log(`Usuario: ${result.value.name}, Email: ${result.value.email}`);
} else {
  // TypeScript sabe que result.error existe
  console.log(`Error: ${result.error.message}`);
}

Aplicaciones prácticas

Las uniones discriminadas son especialmente útiles en varios escenarios:

1. Gestión de estados en aplicaciones

type LoadingState = {
  status: "loading";
};

type SuccessState<T> = {
  status: "success";
  data: T;
};

type ErrorState = {
  status: "error";
  error: string;
};

type State<T> = LoadingState | SuccessState<T> | ErrorState;

// Componente que renderiza diferente UI según el estado
function renderData(state: State<string[]>) {
  switch (state.status) {
    case "loading":
      return "Cargando datos...";
    case "success":
      return `Datos: ${state.data.join(", ")}`;
    case "error":
      return `Error: ${state.error}`;
  }
}

2. Modelado de mensajes en sistemas de comunicación

type TextMessage = {
  kind: "text";
  content: string;
};

type ImageMessage = {
  kind: "image";
  url: string;
  dimensions: { width: number; height: number };
};

type VideoMessage = {
  kind: "video";
  url: string;
  duration: number;
};

type Message = TextMessage | ImageMessage | VideoMessage;

function renderMessage(message: Message) {
  switch (message.kind) {
    case "text":
      return `<div class="text-message">${message.content}</div>`;
    case "image":
      return `<img src="${message.url}" width="${message.dimensions.width}" height="${message.dimensions.height}" />`;
    case "video":
      return `<video src="${message.url}" controls></video><span>${message.duration}s</span>`;
  }
}

3. Modelado de acciones en sistemas de reducción de estado

type AddTodoAction = {
  type: "ADD_TODO";
  text: string;
};

type ToggleTodoAction = {
  type: "TOGGLE_TODO";
  id: number;
};

type DeleteTodoAction = {
  type: "DELETE_TODO";
  id: number;
};

type TodoAction = AddTodoAction | ToggleTodoAction | DeleteTodoAction;

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, {
        id: Date.now(),
        text: action.text,
        completed: false
      }];
    case "TOGGLE_TODO":
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case "DELETE_TODO":
      return state.filter(todo => todo.id !== action.id);
  }
}

Ventajas sobre interfaces y herencia

Las uniones discriminadas ofrecen varias ventajas sobre la herencia tradicional:

  1. Flexibilidad: Permiten modelar tipos que tienen estructuras completamente diferentes.
  2. Seguridad de tipos: El compilador garantiza que accedamos solo a propiedades válidas para cada variante.
  3. Exhaustividad: Podemos verificar que todos los casos posibles sean manejados.
  4. Composición sobre herencia: Promueven un enfoque de composición que suele ser más flexible.
// Enfoque con herencia (menos flexible)
abstract class Vehicle {
  abstract getDescription(): string;
}

class Car extends Vehicle {
  constructor(public doors: number) { super(); }
  
  getDescription() {
    return `Coche con ${this.doors} puertas`;
  }
}

class Bicycle extends Vehicle {
  constructor(public type: string) { super(); }
  
  getDescription() {
    return `Bicicleta tipo ${this.type}`;
  }
}

// Enfoque con unión discriminada (más flexible)
type Car = {
  kind: "car";
  doors: number;
};

type Bicycle = {
  kind: "bicycle";
  type: string;
};

type Vehicle = Car | Bicycle;

function getDescription(vehicle: Vehicle): string {
  switch (vehicle.kind) {
    case "car":
      return `Coche con ${vehicle.doors} puertas`;
    case "bicycle":
      return `Bicicleta tipo ${vehicle.type}`;
  }
}

Las uniones discriminadas son una herramienta esencial en el arsenal de TypeScript, permitiendo modelar datos complejos con múltiples variantes de manera segura y expresiva. Combinadas con el estrechamiento de tipos, proporcionan una forma robusta de manejar diferentes casos en tu código con total seguridad de tipos.

Aprendizajes de esta lección

  • Comprender el uso y sintaxis de los tipos de unión con el operador | para representar valores que pueden ser de varios tipos.
  • Aprender a combinar tipos mediante intersecciones con el operador & para crear tipos que contienen todas las propiedades de los tipos combinados.
  • Entender el concepto de estrechamiento de tipos (type narrowing) para refinar tipos dentro de bloques condicionales y acceder a propiedades específicas.
  • Conocer las uniones discriminadas como patrón para modelar variantes de tipos con propiedades discriminadoras y manejar casos de forma segura y exhaustiva.
  • Diferenciar entre uniones, intersecciones, uniones discriminadas y el tipo any, y aplicar cada uno según el contexto adecuado.

Completa TypeScript y certifícate

Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración