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”:
// 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 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:
// 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:
// 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:
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:
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.
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:
// 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.
Comprueba lo que sabes#
Pregunta 1 de 5
¿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.
Paso 2: Que esté pulido
- Usas readonly en los campos que no deberían cambiar (id, nombre, rol).
- Creas un type alias IdHeroe = number y anotas el campo id con ese alias en lugar de number a secas.
- Añades un campo opcional (apodo?) y lo muestras si existe.
Paso 3: Que sea excelente
- Separas la interface en HeroeBase (identidad, todo readonly) y Heroe extends HeroeBase (añade stats).
- El dominio queda modelado en capas: cualquier objeto con un campo de más, de menos o del tipo equivocado no pasa el type-check.
- Cero any en todo el fichero.
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.
// Solución mejor: propiedades readonly donde tiene sentido, campo opcional para
// datos que pueden no estar, y type alias para un identificador simple.
// Un alias de tipo para el id de un héroe: deja claro en la firma de cualquier
// función que espera un identificador, no un número cualquiera.
type IdHeroe = number;
// La interface usa readonly en los campos que no deberían cambiar nunca.
// nombre, rol e id son datos de origen; sobreescribirlos sería un bug, no una feature.
interface Heroe {
readonly id: IdHeroe;
readonly nombre: string;
readonly rol: string;
partidas: number;
victorias: number;
// apodo es opcional: algunos héroes lo tienen, otros no.
apodo?: string;
}
// TypeScript infiere el tipo del array a partir de la anotación de Heroe[].
const equipo: Heroe[] = [
{
id: 1,
nombre: "Tracer",
rol: "Daño",
partidas: 120,
victorias: 78,
apodo: "La corredora",
},
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 155 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 61 },
];
// El parámetro es Heroe: TypeScript sabe que victorias y partidas existen y son number.
function winrate(heroe: Heroe): number {
return heroe.victorias / heroe.partidas;
}
function formatearPorcentaje(ratio: number): string {
return (ratio * 100).toFixed(1) + "%";
}
// Si el héroe tiene apodo, lo mostramos entre comillas; si no, nada.
equipo.forEach(function (heroe) {
// heroe.apodo puede existir o no: si existe, lo incluimos en la línea.
const extra = heroe.apodo ? ' "' + heroe.apodo + '"' : "";
console.log(
heroe.nombre +
extra +
" (" +
heroe.rol +
"): " +
formatearPorcentaje(winrate(heroe)),
);
}); Por qué es mejor que el anterior
- Añade readonly en nombre y rol: son datos de origen, sobreescribirlos sería un bug. TypeScript lo caza en compilación.
- apodo?: string modela un dato que genuinamente puede no estar, en lugar de forzar un string vacío o un undefined manual.
- type IdHeroe = number no añade lógica, pero hace las firmas más expresivas: quien lee la función sabe que espera un identificador, no un número cualquiera.
// Solución excelente: dominio modelado en capas con extends.
// La interface base describe la identidad de un héroe; la extendida añade
// las estadísticas. Así, si en el futuro necesitas un héroe sin stats
// (p. ej. en un catálogo), tienes un tipo limpio para eso también.
// Identidad del héroe: lo que no cambia nunca.
// readonly en todos los campos: si algo intenta modificarlos, el error salta aquí,
// no en un sitio inesperado del código.
interface HeroeBase {
readonly id: number;
readonly nombre: string;
readonly rol: string;
readonly apodo?: string;
}
// Estadísticas del héroe: extiende la base y añade los campos numéricos.
// Separar la base de las stats permite modelar, por ejemplo, un heroeNuevo
// (sin partidas todavía) con solo HeroeBase, sin forzar zeros artificiales.
interface Heroe extends HeroeBase {
partidas: number;
victorias: number;
}
// El equipo: array de Heroe (que ya incluye HeroeBase por herencia).
const equipo: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78, apodo: "La corredora" },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 155 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 61 },
];
// La firma deja claro el contrato: recibe un Heroe, devuelve un number.
// Si alguien pasa un HeroeBase (sin stats), TypeScript lo marca en rojo aquí.
function winrate(heroe: Heroe): number {
return heroe.victorias / heroe.partidas;
}
function formatearPorcentaje(ratio: number): string {
return (ratio * 100).toFixed(1) + "%";
}
// Pinta el resumen. TypeScript ya sabe que heroe.nombre, heroe.rol y heroe.apodo existen.
equipo.forEach(function(heroe) {
const extra = heroe.apodo ? " \"" + heroe.apodo + "\"" : "";
console.log(heroe.nombre + extra + " (" + heroe.rol + "): " + formatearPorcentaje(winrate(heroe)));
}); Por qué es mejor que el anterior
- Modelar en capas (HeroeBase + Heroe extends HeroeBase) separa responsabilidades: si en el futuro necesitas una lista de héroes sin stats (un catálogo, un buscador), tienes un tipo limpio para eso sin tocar el resto.
- Un objeto con un campo de menos, de más o del tipo equivocado no pasa el type-check. Los tipos "cuentan la historia" del dominio: quien lee el código entiende qué es un Heroe y qué es un HeroeBase sin necesidad de leer los comentarios.
- Cero any: la red de seguridad está intacta de principio a fin del fichero.