learning-front

Nivel 5 · TypeScript: JavaScript con red de seguridad

Interfaces y type aliases

Modelar la forma de los objetos con interface y type, propiedades opcionales y de solo lectura, y cuándo usar cada uno.

En el capítulo anterior tipamos valores sueltos: un nombre es string, unas partidas son number. Eso ya ayuda, pero en un proyecto real casi todo lo interesante vive dentro de objetos: un héroe tiene nombre, rol, partidas y victorias. Si TypeScript no conoce la forma de ese objeto, no puede avisarte cuando accedes a un campo que no existe o le pasas el objeto equivocado a una función.

Para eso existen las interfaces y los type aliases: herramientas para describir la forma de los objetos y darle nombres a esos tipos, de modo que puedas reutilizarlos en todo el código.

interface: describir la forma de un objeto#

Una interface es un contrato de forma. Le dices a TypeScript “cualquier objeto de este tipo tendrá exactamente estos campos, con estos tipos”:

typescript
// interface describe qué campos tiene el objeto y de qué tipo es cada uno.
interface Heroe {
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
}

A partir de ese momento, puedes usar Heroe como tipo:

typescript
// TypeScript comprueba que el objeto cumple la forma.
const tracer: Heroe = {
  nombre: "Tracer",
  rol: "Daño",
  partidas: 120,
  victorias: 78,
};

Si el objeto no cumple la forma —le falta un campo, tiene uno de más, o un campo es del tipo equivocado— TypeScript lo marca en rojo antes de ejecutar. Descomenta las líneas del editor para ver los errores en el panel “Problemas”.

type alias: otro nombre para cualquier tipo#

type también sirve para describir objetos, con una sintaxis casi idéntica:

typescript
// type alias para un objeto: la forma entre llaves, igual que interface.
type Heroe = {
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
};

La diferencia práctica es que type sirve para nombrar cualquier tipo, no solo objetos. Por ejemplo, un identificador numérico:

typescript
// Darle nombre a un tipo primitivo: las firmas quedan más expresivas.
type IdHeroe = number;

Cuando uses IdHeroe en la firma de una función, quien lea el código sabrá que ese number es un identificador, no una cantidad cualquiera.

¿Cuándo usar cada uno? La convención en TypeScript es usar interface para describir objetos y APIs (se puede extender con extends, lo que facilita componer tipos en capas), y type para darle nombre a tipos que no son objetos —un primitivo como IdHeroe, o composiciones más complejas que veremos en capítulos futuros—. En la práctica, para objetos simples los dos funcionan igual.

Propiedades opcionales con ?#

No todos los objetos tienen los mismos campos. Algunos héroes tienen apodo, otros no. Marcar un campo como opcional con ? le dice a TypeScript que puede estar o no estar:

typescript
interface Heroe {
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
  // apodo puede estar o no estar; si quieres usarlo, comprueba primero si existe.
  apodo?: string;
}

Un objeto sin apodo sigue siendo un Heroe válido. TypeScript no protesta. Pero cuando accedes al campo, te recuerda que puede ser undefined: si quieres usarlo, conviene comprobar primero si existe.

Campos de solo lectura con readonly#

Algunos campos no deberían cambiar una vez creado el objeto. El nombre y el rol de un héroe son datos de origen: si algo los sobreescribe, es casi siempre un bug. readonly lo convierte en un error de compilación:

typescript
interface Heroe {
  // nombre y rol no se pueden reasignar: son la identidad del héroe.
  readonly nombre: string;
  readonly rol: string;
  // partidas y victorias sí se actualizan con cada partida.
  partidas: number;
  victorias: number;
}

Si algo intenta reasignar un campo readonly, TypeScript lo marca antes de ejecutar. Al compilar a JavaScript, readonly desaparece —como todos los tipos—, pero su valor está en pillarte el error en desarrollo, no en producción.

readonly también en arrays y tuplas#

readonly no se queda en los campos de un objeto. Puedes marcar un array entero como de solo lectura, y entonces TypeScript bloquea los métodos que lo mutarían (push, pop, sort…):

typescript
// readonly number[]: una lista que nadie puede modificar tras crearla.
const winrates: readonly number[] = [65, 71, 58];
// Leer y recorrer: bien.
const mejor = winrates[0];
// Mutar: error. Descomenta para verlo:
// winrates.push(80); // 'push' no existe en 'readonly number[]'.

Y lo mismo con una tupla (las viste en el capítulo anterior): readonly [string, number] fija la forma y además prohíbe reasignar sus posiciones.

typescript
// Una fila de ranking que no debe tocarse una vez calculada.
const entrada: readonly [string, number] = ["Tracer", 65];
// entrada[1] = 99; // Error: no se puede asignar a un índice de solo lectura.

Frente al readonly de un campo, esto es lo mismo pero para colecciones: comunica “esto es un dato de consulta, no lo mutes” y convierte en error cualquier intento de modificarlo. Muy habitual en datos que llegan de fuera y solo se leen.

Componer interfaces con extends#

A medida que el dominio crece, conviene separar responsabilidades. extends permite crear una interface que hereda todos los campos de otra y añade los suyos:

typescript
// La identidad: lo que define quién es el héroe.
interface HeroeBase {
  readonly nombre: string;
  readonly rol: string;
}

// Las estadísticas: extiende la base y añade los campos de juego.
interface HeroeConStats extends HeroeBase {
  partidas: number;
  victorias: number;
}

Un objeto de tipo HeroeConStats cumple también HeroeBase (tiene todos sus campos), así que puedes pasárselo a cualquier función que pida la base. Al revés no: un HeroeBase no tiene las estadísticas, así que no puedes usarlo donde se pida HeroeConStats.

Tipado estructural: encajar por forma, no por nombre#

Has visto que un objeto “cumple” una interface si tiene los campos correctos, y que un HeroeConStats vale donde se pide un HeroeBase. Eso tiene nombre: TypeScript usa tipado estructural. Lo que decide si un valor encaja en un tipo es su forma (qué campos tiene y de qué tipo), no cómo se llame el tipo ni de dónde salga. Lenguajes como Java o C# usan tipado nominal: allí declaras explícitamente “esta clase implementa esa interface”. En TypeScript no hace falta: si la forma encaja, encaja.

A la pregunta “¿este valor encaja donde espero este tipo?” se le llama assignability (asignabilidad), y la regla corta es: un valor es asignable a un tipo si tiene al menos los campos que el tipo exige, con tipos compatibles. Por eso un objeto con campos de sobra se puede pasar a una función que pide menos:

typescript
// La función solo pide 'nombre'.
function saludar(x: { nombre: string }): string {
  return "Hola, " + x.nombre;
}
// tracer tiene 'nombre' y de sobra: encaja.
const tracer = { nombre: "Tracer", rol: "Daño" };
saludar(tracer);

El excess property check (cuándo un campo de más SÍ falla)#

Si la regla es “al menos los campos que se piden”, ¿por qué el primer ejemplo del capítulo —el del campo nivel: 42 de más— daba error? Por una excepción: cuando asignas un objeto literal recién escrito directamente a un tipo, TypeScript añade un control extra —el excess property check— y rechaza los campos sobrantes. Es una red contra erratas (nivle por nivel).

La consecuencia, que es lo que confunde: ese control solo salta con literales frescos. Si el objeto pasa antes por una variable, vuelve a mandar el tipado estructural puro (“al menos los campos”) y el campo de más se acepta:

typescript
// Como literal directo daría error; como variable, NO:
const conExtra = { nombre: "Mercy", rol: "Apoyo", nivel: 42 };
// conExtra tiene "al menos" lo que pide la función → pasa, con nivel y todo.
saludar(conExtra);

¿Y qué? Que si dabas por hecho que TypeScript caza siempre los campos de más, no es así: solo con literales directos. Saberlo te ahorra el “pero si yo lo había tipado” cuando un objeto con basura de más se cuela por haber pasado por una variable intermedia (justo lo que ocurre con los datos que llegan de una API).

Comprueba lo que sabes#

Pregunta 1 de 6

¿Qué describe una `interface` en TypeScript?

Tu turno#

Tienes el array del equipo sin tipar: TypeScript no conoce la forma de los objetos y el panel “Problemas” lo indica. Define la interface, anótala donde toca y deja el panel vacío. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.

Ejercicio · en esta página

Modela el equipo del Team Builder

Partes de un fichero con objetos de héroe sin tipar: TypeScript no conoce su forma y el panel "Problemas" lo indica. Define la interface que describe un héroe y úsala para tipar el array y las funciones.

Paso 1: Que funcione

  • Defines una interface con los cinco campos del héroe (id, nombre, rol, partidas, victorias).
  • El array y el parámetro de la función quedan anotados con ese tipo.
  • El panel "Problemas" queda vacío y la consola muestra el resumen.
Ver soluciones
// Solución OK: una interface que describe el héroe y resuelve el problema.
// Funciona y compila limpio; es la traducción más directa del enunciado.

// Una interface con los cinco campos que tiene un héroe.
interface Heroe {
  id: number;
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
}

// El array queda tipado: TypeScript ya sabe que cada elemento es un Heroe.
const equipo: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 155 },
  { id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 61 },
];

// El parámetro anotado: TypeScript ya sabe que heroe.victorias y heroe.partidas existen.
function winrate(heroe: Heroe): number {
  return heroe.victorias / heroe.partidas;
}

// El parámetro y el retorno anotados.
function formatearPorcentaje(ratio: number): string {
  return (ratio * 100).toFixed(1) + "%";
}

// Pinta el resumen del equipo.
equipo.forEach(function (heroe) {
  console.log(
    heroe.nombre +
      " (" +
      heroe.rol +
      "): " +
      formatearPorcentaje(winrate(heroe)),
  );
});

Por qué este nivel

  • Define una interface con los cinco campos y la usa para tipar el array y el parámetro: traducción directa del enunciado, compila y funciona.
  • El panel "Problemas" queda vacío: ya es suficiente para llamarlo "correcto".
  • Su límite: todos los campos son mutables (cualquiera puede reasignar nombre o rol sin que TypeScript proteste) y no hay distinción entre campos de identidad y campos de juego.