Llevas varios capítulos programando con tipos. Ahora vas a ver dos mecanismos que tienen un propósito diferente: no transformar tipos, sino describir código que vive fuera de TypeScript.
El primero es declaration merging. El segundo es declare y los ficheros .d.ts. Los dos responden a la misma pregunta práctica: ¿cómo le doy tipos a algo que TypeScript no ve directamente?
Declaration merging: dos interfaces, un solo tipo#
En TypeScript, declarar dos veces el mismo nombre con interface no es un error. Es una fusión: el compilador suma las dos declaraciones en un único tipo con todos los campos de ambas.
// Primera declaración: los campos de identidad.
interface Heroe {
nombre: string;
rol: string;
}
// Segunda declaración del MISMO nombre: TypeScript las fusiona.
interface Heroe {
partidas: number;
victorias: number;
}
// El tipo Heroe ahora tiene los cuatro campos de las dos declaraciones.
const tracer: Heroe = {
nombre: "Tracer",
rol: "Daño",
partidas: 120,
victorias: 78,
};¿Y qué? Si solo trabajaras en un fichero propio, siempre podrías meter los cuatro campos en un solo bloque. La fusión importa cuando no puedes tocar la declaración original: la sacó una librería, la define otro módulo, o ya está publicada. Con declaration merging puedes añadirle campos desde tu código sin modificar la fuente.
type no se puede redeclarar#
La fusión es exclusiva de interface. Si intentas declarar un type alias dos veces con el mismo nombre, TypeScript marca error de compilación inmediatamente: Duplicate identifier.
// Esta segunda declaración es un error: "Duplicate identifier 'Heroe'".
type Heroe = { nombre: string };
type Heroe = { partidas: number };Esa diferencia no es un capricho del diseño: type crea un nombre inmutable, una definición sellada. interface está pensada para ser extensible: su contrato puede recibir nuevos campos desde otros módulos. Por eso las librerías que quieren ser ampliables exponen interfaces, no types.
declare: describir algo que existe en otro sitio#
TypeScript solo type-chequea lo que puede ver en su grafo de ficheros. Si una variable llega de un script externo, de una config de servidor o de una inyección del entorno, TypeScript la desconoce y marca error: Cannot find name 'TEAM_SIZE'.
La palabra clave declare resuelve eso. Le dice al compilador: “esto existe, confía en mí, no lo implementes”.
// TypeScript sabrá que TEAM_SIZE es number aunque no esté inicializado aquí.
declare const TEAM_SIZE: number;
// Ahora podemos usarla como un number sin error.
function puedeUnirse(equipo: Heroe[]): boolean {
return equipo.length < TEAM_SIZE;
}declare no genera ningún código JavaScript. Es solo información para el compilador: un contrato que tú firmas y TypeScript acepta.
Ficheros .d.ts: solo tipos, sin implementación#
Un fichero .d.ts (declaration file) es un fichero de solo declaraciones. No tiene lógica, no se ejecuta. Es como un manifiesto que le dice a TypeScript qué formas tiene el código que vive fuera de su grafo.
Los ves aparecer en dos situaciones habituales:
1. Al compilar tu propio proyecto con declaration: true.
TypeScript genera un .d.ts por cada fichero: es la “capa pública de tipos” que otros pueden consumir sin necesitar el código fuente.
2. Al instalar @types/alguna-libreria.
Muchas librerías populares (lodash, express, jquery) están escritas en JavaScript sin tipos. La comunidad escribe ficheros .d.ts para ellas y los publica en el paquete @types. Cuando instalas @types/lodash, TypeScript sabe exactamente qué devuelve _.groupBy aunque lodash no tenga una línea de TypeScript.
Dentro de un .d.ts (o inline con declare module) puedes describir la forma completa de una librería:
// En "team-stats.d.ts": describe los tipos de la librería "team-stats".
declare module "team-stats" {
export function resumen(heroes: { nombre: string; victorias: number }[]): string;
export function tasaVictoria(heroes: { partidas: number; victorias: number }[]): number;
}¿Y qué pasa sin el .d.ts? TypeScript no sabe nada de esa librería y trata todo lo que importes de ella como any. Pierdes todos los avisos del compilador: puedes llamar a una función con los argumentos equivocados, escribir mal el nombre de una propiedad, o esperar un string donde llega un number, y TypeScript no dirá nada. El .d.ts es la red de seguridad.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Qué ocurre cuando declaras dos veces la misma `interface` en TypeScript?
Tu turno#
Fusiona interfaces, describe globals con declare y amplía una interfaz de “librería” sin modificarla. Completa los TODOs hasta que los tests queden sin subrayado rojo.
Ejercicio · en esta página
Declaration merging y declare en el Team Builder
Amplía la interfaz Heroe con declaration merging, describe una global con declare y amplía una interfaz de "librería" con interface augmentation.
Paso 1: Que funcione
- Declaras interface Heroe una segunda vez con los campos partidas y victorias.
- TypeScript fusiona ambas declaraciones: el tipo resultante tiene los cuatro campos.
- El test _Test1 (keyof Heroe) está en verde.
Paso 2: Que describa el entorno
- Usas declare const TEAM_SIZE: number para describir una global sin implementarla.
- TypeScript acepta usar TEAM_SIZE como number sin error aunque no la hayas inicializado.
- Los tests _Test1 y _Test2 están en verde.
Paso 3: Que amplíe sin modificar
- Añades un segundo bloque interface ConfigEquipo con el campo nombreEquipo: string.
- El tipo ConfigEquipo fusionado tiene los dos campos: maxJugadores y nombreEquipo.
- Entiendes que este es el mismo mecanismo que usan las librerías para ser ampliables.
- Los tres tests están en verde.
Ver soluciones
// ─── Tipos base del Team Builder (no los modifiques) ───────────────────────
type Rol = "Daño" | "Tanque" | "Apoyo";
// Primera declaración de Heroe: los campos de identidad.
interface Heroe {
nombre: string;
rol: Rol;
}
// NIVEL OK — Declaration merging: segunda declaración del MISMO nombre.
// TypeScript fusiona ambas en una sola: Heroe tiene nombre, rol, partidas y victorias.
interface Heroe {
partidas: number;
victorias: number;
}
// ─── Comprobación: usa el tipo combinado ────────────────────────────────────
// Heroe ahora exige los cuatro campos (los dos bloques fusionados).
const tracer: Heroe = {
nombre: "Tracer",
rol: "Daño",
partidas: 120,
victorias: 78,
};
// Calcula la tasa de victorias con los campos del segundo bloque.
// porcentaje es number (calculado a partir de victorias y partidas).
const porcentaje = (tracer.victorias / tracer.partidas) * 100;
console.log("Heroe: " + tracer.nombre);
console.log("Tasa de victoria: " + porcentaje.toFixed(1) + "%");
// ─── Test de tipos ──────────────────────────────────────────────────────────
// Equal compara dos tipos; Expect falla en compilación si no son iguales.
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// El tipo combinado debe tener los cuatro campos. Si la fusión no funciona, rojo.
type _Test1 = Expect<Equal<
keyof Heroe,
"nombre" | "rol" | "partidas" | "victorias"
>>;
export {}; Por qué este nivel
- La segunda declaración de interface Heroe no reemplaza a la primera: TypeScript las fusiona. El tipo resultante tiene los cuatro campos.
- Es la diferencia práctica más importante entre interface y type: un type alias no se puede redeclarar, una interface sí.
- Funciona, pero solo toca la interfaz propia. Los dos tiers siguientes muestran cómo describir código que está fuera del fichero.
// ─── Tipos base del Team Builder (no los modifiques) ───────────────────────
type Rol = "Daño" | "Tanque" | "Apoyo";
interface Heroe {
nombre: string;
rol: Rol;
}
// NIVEL MEJOR — Declaration merging + declare de una global.
// Segundo bloque: fusiona los campos de estadísticas.
interface Heroe {
partidas: number;
victorias: number;
}
// ─── declare: describe algo que existe fuera de TypeScript ─────────────────
// En un proyecto real esto iría en un fichero .d.ts, pero dentro del
// playground lo declaramos aquí para enseñar la sintaxis.
// Le decimos a TypeScript: "existe una variable global llamada TEAM_SIZE
// que ya está inicializada en otro sitio (configura el tamaño del equipo)".
declare const TEAM_SIZE: number;
// Ahora TypeScript sabe que TEAM_SIZE existe y es number: podemos usarla
// sin error aunque no la hayamos inicializado nosotros.
function puedeUnirseAlEquipo(equipo: Heroe[]): boolean {
// equipo.length es number y TEAM_SIZE es number: la comparación es segura.
return equipo.length < TEAM_SIZE;
}
// ─── Comprobación ───────────────────────────────────────────────────────────
const tracer: Heroe = {
nombre: "Tracer",
rol: "Daño",
partidas: 120,
victorias: 78,
};
const equipo: Heroe[] = [tracer];
console.log("TEAM_SIZE declarado como global: TypeScript no marca error.");
console.log("Puede unirse: " + puedeUnirseAlEquipo(equipo));
// ─── Test de tipos ──────────────────────────────────────────────────────────
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// Heroe fusionado tiene los cuatro campos.
type _Test1 = Expect<Equal<
keyof Heroe,
"nombre" | "rol" | "partidas" | "victorias"
>>;
// TEAM_SIZE es number (no any ni unknown gracias al declare).
type _Test2 = Expect<Equal<typeof TEAM_SIZE, number>>;
export {}; Por qué es mejor que el anterior
- declare const TEAM_SIZE: number no genera código: le dice al compilador que esa variable existe en otro sitio y que es number.
- Sin el declare, TypeScript marcaría "Cannot find name TEAM_SIZE" aunque la variable estuviera disponible en runtime.
- Es el patrón detrás de los .d.ts: describir formas sin implementar. El salto a excelente muestra cómo ampliar interfaces de terceros.
// ─── Tipos base del Team Builder (no los modifiques) ───────────────────────
type Rol = "Daño" | "Tanque" | "Apoyo";
interface Heroe {
nombre: string;
rol: Rol;
}
// NIVEL EXCELENTE — Declaration merging + declare global + interface augmentation.
// Segundo bloque: fusiona estadísticas con declaration merging.
interface Heroe {
partidas: number;
victorias: number;
}
// ─── declare global: ampliar el espacio de nombres global ──────────────────
// Este fichero es un módulo (tiene "export {}" al final), así que para
// ampliar el espacio global hay que envolver el declare en "declare global".
// En un proyecto real esto iría en un .d.ts; aquí lo ponemos inline para verlo.
// Le decimos a TypeScript que existe una variable global TEAM_SIZE.
declare global {
// eslint-disable-next-line no-var
var TEAM_SIZE: number;
}
// ─── Interface augmentation: ampliar una interfaz de librería ──────────────
// Mismo mecanismo que declaration merging, pero cruzando módulos.
// Imagina que una librería exporta esta interfaz (la simulamos aquí):
interface ConfigEquipo {
maxJugadores: number;
}
// Ahora la AMPLÍAS añadiendo un campo propio sin tocar la librería.
// En un proyecto real harías: import { ConfigEquipo } from "lib"; interface ConfigEquipo { ... }
interface ConfigEquipo {
nombreEquipo: string;
}
// ConfigEquipo fusionado tiene ambos campos: maxJugadores y nombreEquipo.
const config: ConfigEquipo = {
maxJugadores: 5,
nombreEquipo: "Overwatch Team",
};
// ─── Comprobación combinada ─────────────────────────────────────────────────
const equipo: Heroe[] = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 61 },
{ nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 55 },
];
// Heroe tiene los cuatro campos gracias a la fusión del primer bloque.
// TEAM_SIZE es tipado como number gracias al declare global.
// config tiene los dos campos gracias a la fusión de ConfigEquipo.
console.log("Equipo: " + config.nombreEquipo);
console.log("Max jugadores: " + config.maxJugadores);
console.log("Heroes: " + equipo.map(function(h) { return h.nombre; }).join(", "));
// ─── Tests de tipos ─────────────────────────────────────────────────────────
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// Heroe fusionado tiene los cuatro campos.
type _Test1 = Expect<Equal<
keyof Heroe,
"nombre" | "rol" | "partidas" | "victorias"
>>;
// TEAM_SIZE es number gracias al declare global.
type _Test2 = Expect<Equal<typeof globalThis.TEAM_SIZE, number>>;
// ConfigEquipo fusionado tiene los dos campos (interface augmentation).
type _Test3 = Expect<Equal<
keyof ConfigEquipo,
"maxJugadores" | "nombreEquipo"
>>;
export {}; Por qué es mejor que el anterior
- Añadir campos a ConfigEquipo con un segundo bloque es interface augmentation: el mismo mecanismo que declaration merging, aplicado a ampliar el contrato de otro módulo.
- En un proyecto real harías: import { ConfigEquipo } from "alguna-libreria" y luego interface ConfigEquipo { miCampo: string }. TypeScript fusiona la declaración de la librería con la tuya.
- Ese patrón es el que usan librerías como Express (express.Request) o Vue (ComponentCustomProperties) para que puedas añadir tus propios campos sin modificar su código.