En esta página

Interfaces y type aliases

15 min lectura TextoCap. 2 — Tipos compuestos

Interfaces y type aliases

TypeScript tiene dos mecanismos principales para dar nombre a la estructura de los objetos: las interfaces y los type aliases. Aunque se solapan en muchos casos, cada uno tiene características únicas que los hacen más adecuados para diferentes situaciones. Entender sus diferencias y sus puntos fuertes te permitirá diseñar código más expresivo y mantenible.

Type aliases: nombrar cualquier tipo

Un type alias simplemente da un nombre a un tipo existente. Puede nombrar cualquier cosa: primitivos, uniones, intersecciones, funciones, tuplas, objetos o tipos utilitarios.

// Alias para tipos primitivos
type ID = string;
type Porcentaje = number;
type Activo = boolean;

// Alias para un objeto
type Punto = {
  x: number;
  y: number;
};

// Alias para una función
type Comparador<T> = (a: T, b: T) => number;

// Alias para una unión
type StringONúmero = string | number;

// Alias para una tupla
type Coordenadas = [latitud: number, longitud: number];

Los type aliases no crean tipos nuevos; crean nombres alternativos para tipos existentes. Cuando TypeScript reporta un error, puede usar el nombre del alias o desempaquetarlo según el contexto.

Interfaces: contratos para objetos y clases

Las interfaces están diseñadas específicamente para describir la forma de los objetos. Tienen una sintaxis más orientada a objetos y algunas características que los type aliases no tienen.

interface Punto {
  x: number;
  y: number;
}

interface Punto3D extends Punto {
  z: number;
}

const punto: Punto3D = { x: 1, y: 2, z: 3 };

La diferencia sintáctica más visible es que las interfaces usan extends para heredar, mientras que los type aliases usan intersección (&).

Propiedades opcionales y de solo lectura

Tanto interfaces como type aliases soportan propiedades opcionales (?) y de solo lectura (readonly):

interface ConfigurarServidor {
  host: string;
  puerto: number;
  ssl?: boolean;          // Opcional: puede no estar presente
  timeout?: number;       // Opcional con valor por defecto en la función que lo usa
  readonly versión: string; // No se puede modificar después de la creación
}

function iniciarServidor(config: ConfigurarServidor): void {
  const ssl = config.ssl ?? false;      // Valor por defecto para opcionales
  const timeout = config.timeout ?? 30000;
  console.log(`Iniciando en ${config.host}:${config.puerto} (SSL: ${ssl})`);
}

// config.versión = '2.0'; // ❌ Error: Cannot assign to 'versión' because it is a read-only property

Diferencia clave entre `?` y `| undefined`

interface ConOpcional {
  valor?: string; // La propiedad puede no existir en el objeto
}

interface ConUnión {
  valor: string | undefined; // La propiedad debe existir, pero puede ser undefined
}

const obj1: ConOpcional = {};                // ✅
const obj2: ConUnión = {};                   // ❌ Error: property 'valor' is missing
const obj3: ConUnión = { valor: undefined }; // ✅

Con exactOptionalPropertyTypes: true en tsconfig, esta distinción se vuelve aún más estricta.

Extendiendo interfaces

Las interfaces se pueden extender de una o múltiples otras interfaces:

interface EntidadBase {
  readonly id: string;
  readonly creadoEn: Date;
  actualizadoEn?: Date;
}

interface Persona extends EntidadBase {
  nombre: string;
  email: string;
}

interface Empleado extends Persona {
  departamento: string;
  salario: number;
  fechaIngreso: Date;
}

interface Gerente extends Empleado {
  equipoIds: string[];
  presupuesto: number;
}

Cada nivel de extensión hereda todas las propiedades de los padres. Gerente tiene todas las propiedades de EntidadBase, Persona, Empleado, y las propias.

Intersección de types: combinación horizontal

Con type aliases, la combinación se hace con el operador &:

type ConTimestamps = {
  creadoEn: Date;
  actualizadoEn?: Date;
};

type ConAuditoria = {
  creadoPor: string;
  actualizadoPor?: string;
};

type EntidadAuditada = ConTimestamps & ConAuditoria & {
  readonly id: string;
};

const entidad: EntidadAuditada = {
  id: 'ent-001',
  creadoEn: new Date(),
  creadoPor: 'usuario-123',
};

La intersección combina todos los tipos en uno. Si dos tipos tienen una propiedad con el mismo nombre pero tipos incompatibles, la intersección resulta en never para esa propiedad (un tipo imposible de satisfacer).

Index signatures: objetos con claves dinámicas

Cuando no conoces las claves de un objeto de antemano pero sí conoces el tipo de los valores:

interface GlosarioTerminos {
  [término: string]: string; // Clave: cualquier string, Valor: string
}

const glosario: GlosarioTerminos = {
  TypeScript: 'Superconjunto tipado de JavaScript',
  Interface: 'Contrato que describe la forma de un objeto',
  Generic: 'Componente reutilizable parametrizado por tipo',
};

// Cuidado: con noUncheckedIndexedAccess, el tipo es string | undefined
const definición = glosario['TypeScript']; // string | undefined

Limitación de los index signatures: si tienes tanto un index signature como propiedades específicas, las propiedades específicas deben ser compatibles con el tipo del index:

interface Mixto {
  [clave: string]: string | number; // Index signature
  nombre: string;    // ✅ string es subconjunto de string | number
  edad: number;      // ✅ number es subconjunto de string | number
  // activo: boolean; // ❌ boolean no es subconjunto de string | number
}

Una alternativa más precisa es Record<string, T> o Map<string, T>:

// Record: tipo utilitario para objetos con claves tipadas
type PreciosPorMoneda = Record<string, number>;
type EstadoPorRol = Record<'admin' | 'editor' | 'lector', boolean>;

Declaration merging: la característica exclusiva de interfaces

Las interfaces con el mismo nombre en el mismo scope se fusionan automáticamente. Esto no es posible con type aliases.

interface Config {
  host: string;
  puerto: number;
}

interface Config {
  ssl: boolean;
  // Ahora Config tiene host, puerto y ssl
}

const config: Config = { host: 'localhost', puerto: 3000, ssl: true }; // ✅

Esto es especialmente útil para module augmentation: extender tipos de librerías externas sin modificar sus archivos.

// Extender la interfaz Request de Express:
declare module 'express' {
  interface Request {
    usuario?: UsuarioAutenticado;
  }
}

// Ahora en cualquier handler de Express:
app.get('/perfil', (req, res) => {
  const usuario = req.usuario; // TypeScript conoce este campo
});

Patrones prácticos con interfaces

Tipado de respuestas de API

interface ErrorAPI {
  código: string;
  mensaje: string;
  detalles?: string[];
}

interface RespuestaExitosa<T> {
  datos: T;
  paginación?: {
    página: number;
    porPágina: number;
    total: number;
    totalPáginas: number;
  };
}

type Respuesta<T> = RespuestaExitosa<T> | { error: ErrorAPI };

async function obtenerUsuario(id: string): Promise<Respuesta<Usuario>> {
  const res = await fetch(`/api/usuarios/${id}`);
  if (!res.ok) {
    const error: ErrorAPI = await res.json() as ErrorAPI;
    return { error };
  }
  const datos: Usuario = await res.json() as Usuario;
  return { datos };
}

Interfaces para configuración de componentes

interface PropiedadesModal {
  readonly abierto: boolean;
  titulo: string;
  contenido: string;
  onCerrar: () => void;
  onConfirmar?: () => void;
  variante?: 'info' | 'advertencia' | 'peligro' | 'éxito';
  tamaño?: 'pequeño' | 'mediano' | 'grande';
}

Separar lectura de escritura

Un patrón muy útil es tener interfaces separadas para crear y leer datos:

// Lo que necesitas para crear un usuario
interface CrearUsuarioDto {
  nombre: string;
  email: string;
  contraseña: string;
  rol?: 'admin' | 'editor' | 'lector';
}

// Lo que retorna la API (sin contraseña, con metadatos)
interface UsuarioDto {
  readonly id: string;
  nombre: string;
  email: string;
  rol: 'admin' | 'editor' | 'lector';
  readonly creadoEn: string; // ISO string desde la API
  avatarUrl?: string;
}

// Lo que el cliente actualiza
interface ActualizarUsuarioDto {
  nombre?: string;
  avatarUrl?: string;
}

Tipos utilitarios con interfaces y types

TypeScript incluye tipos utilitarios que transforman interfaces existentes:

interface Usuario {
  id: string;
  nombre: string;
  email: string;
  edad: number;
  activo: boolean;
}

type UsuarioParcial = Partial<Usuario>;        // Todas las props opcionales
type UsuarioCompleto = Required<Usuario>;      // Todas las props requeridas
type UsuarioInmutable = Readonly<Usuario>;     // Todas las props readonly
type UsuarioPreview = Pick<Usuario, 'id' | 'nombre' | 'email'>; // Solo algunas
type UsuarioSinId = Omit<Usuario, 'id'>;       // Todas menos algunas

Estos tipos utilitarios son especialmente poderosos con genéricos, que exploraremos más adelante.

interface vs type: la decisión final

La comunidad TypeScript ha convergido en estas convenciones:

Usa interface para:

  • Contratos de objetos y clases (interface Usuario, interface Repositorio).
  • Cuando necesites extender con extends de forma jerárquica.
  • Cuando necesites declaration merging (module augmentation).
  • APIs públicas de librerías (los errores son más claros).

Usa type para:

  • Uniones y tipos condicionales (type Estado = 'activo' | 'inactivo').
  • Tipos calculados y transformados (type Parcial = Partial<Config>).
  • Funciones y callbacks (type Handler = (evento: Event) => void).
  • Tuplas y tipos primitivos renombrados.

En la práctica, muchos equipos usan interface por defecto y type cuando necesitan algo que interface no puede hacer. Lo más importante es la consistencia dentro de un proyecto.

En la próxima lección profundizamos en los tipos de unión e intersección, incluyendo el poderoso patrón de discriminated unions para modelar estados complejos.

interface vs type: regla práctica
Usa 'interface' para describir la forma de objetos y contratos de clase. Usa 'type' para unions, intersecciones, tipos calculados (Partial, Pick, etc.) y cuando necesitas un alias para un tipo primitivo o función. En caso de duda entre los dos, 'interface' es ligeramente preferida por su mejor mensaje de error y soporte para declaration merging.
Index signatures con restricciones
Un index signature '[key: string]: T' acepta cualquier clave string. Para ser más preciso, usa 'Record<string, T>' o limita las claves con un union type: Record<'activo' | 'inactivo' | 'pendiente', number>. Esto da exactamente las claves que necesitas sin aceptar claves arbitrarias.
Declaration merging: potente pero peligroso
Las interfaces se pueden 'reabrir' para añadir propiedades. Esto es útil para extender tipos de librerías (module augmentation), pero en tu propio código puede generar confusión si la misma interfaz se define en múltiples lugares. Los type aliases no tienen esta característica: redefinir uno es un error de compilación.