learning-front

Extra · Type-level TypeScript (opcional)

Declaration merging y .d.ts

Cómo dos declaraciones del mismo nombre se fusionan en una sola, qué es declare y para qué sirven los ficheros .d.ts cuando tipas código que vive fuera de TypeScript.

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.

typescript
// 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.

typescript
// 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
// 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:

typescript
// 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.
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.