Escribir una biblioteca TypeScript con type declaration

1. Descripción general

Los usuarios de tu biblioteca TypeScript necesitan dos cosas: código JavaScript para ejecutar y archivos type declaration (.d.ts) para que el editor sugiera autocompletado y el compilador capture errores. Si falta .d.ts o package.json apunta al archivo incorrecto, los usuarios recibirán any en todas partes o el error Could not find a declaration file for module.

Este artículo construye una pequeña biblioteca llamada order-pricing para calcular precios de pedidos. Al terminarlo, sabrás cómo configurar tsconfig.json para generar .d.ts, declarar exports en package.json, escribir declarations manualmente para código JavaScript antiguo, y probar runtime y tipos antes de publicar.

2. Inicializar el proyecto

Creas la carpeta e instala las herramientas necesarias:

mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest

typescript solo va en devDependencies. Los usuarios de la biblioteca no necesitan el compilador, solo usan los archivos .js y .d.ts ya compilados.

2.1. Configurar tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "isolatedDeclarations": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

Significado de las opciones importantes:

  • declaration: true le pide a tsc generar archivos .d.ts junto a cada archivo .js. Esta es la opción central del artículo.
  • declarationMap: true genera archivos .d.ts.map. Cuando los usuarios hacen clic en “Go to Definition”, el editor abre el archivo .ts original en lugar del .d.ts. Para que esto funcione, debes publicar junto con la carpeta src.
  • module y moduleResolution se establecen en NodeNext para que TypeScript resuelva módulos exactamente como Node.js. Las bibliotecas compiladas de esta forma funcionan tanto en bundlers como en Node.
  • isolatedDeclarations (disponible desde TypeScript 5.5) obliga a que toda API exportada declare su tipo explícitamente, como el tipo de retorno de una función. Gracias a esto, otras herramientas pueden generar .d.ts sin ejecutar el type checker. Las APIs públicas también se vuelven más transparentes.
  • exclude saca los archivos de test de la compilación, para que no terminen en dist.

2.2. Configurar package.json

{
  "name": "order-pricing",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "src"],
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "prepublishOnly": "npm run test && npm run build"
  }
}

Puntos a notar:

  • "type": "module" hace que Node.js interprete los archivos .js en dist como ESM.
  • En exports, la condición types debe venir primero. TypeScript recorre las condiciones en orden y se detiene en la primera que coincide. Si default viene primero, TypeScript nunca llegará a leer types.
  • main y types al nivel raíz son un respaldo para herramientas antiguas que no leen exports.
  • files limita qué se sube a npm. Sin este campo, es fácil publicar accidentalmente node_modules o archivos de configuración internos.

3. Escribir el código de la biblioteca

3.1. Definir tipos de datos

// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";

export interface Customer {
  id: string;
  name: string;
  tier: CustomerTier;
}

export interface OrderItem {
  sku: string;
  unitPrice: number;
  quantity: number;
}

export interface Order {
  id: string;
  customer: Customer;
  items: readonly OrderItem[];
}

items se declara como readonly OrderItem[]. Esto permite que los usuarios pasen tanto arrays normales como arrays readonly. La biblioteca también se compromete a no modificar el array del usuario.

3.2. Lógica de cálculo de precios

// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";

const DISCOUNT_RATE: Record<CustomerTier, number> = {
  regular: 0,
  silver: 0.05,
  gold: 0.1,
};

/** Monto total del pedido antes del descuento, en VND. */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** Monto total después de aplicar el descuento por nivel de cliente. */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

En el código anterior:

  • import type le dice al compilador que esto es solo un import de tipos. Esta línea se elimina completamente del archivo .js después de compilar.
  • La ruta de importación se escribe como ./types.js aunque el archivo fuente sea types.ts. Con NodeNext, esta es la forma requerida, porque Node buscará archivos .js al ejecutar.
  • DISCOUNT_RATE no se exporta, así que es un detalle interno y no aparece en .d.ts.
  • Los comentarios con formato /** ... */ se conservan en .d.ts. Los usuarios los verán cuando pasen el cursor en el editor.

3.3. Punto de entrada (entry point)

// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";

index.ts define la API pública de la biblioteca. Debes exportar también los tipos de datos. Si no lo haces, los usuarios tendrían que escribir Parameters<typeof calculateTotal>[0] solo para obtener el tipo Order.

4. Compilar y ver archivos .d.ts

npm run build

Estructura de la carpeta dist después de compilar:

dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map

Contenido de dist/pricing.d.ts:

import type { Order } from "./types.js";
/** Monto total del pedido antes del descuento, en VND. */
export declare function calculateSubtotal(order: Order): number;
/** Monto total después de aplicar el descuento por nivel de cliente. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

Este archivo solo contiene las firmas, no los cuerpos de las funciones. DISCOUNT_RATE tampoco está presente. dist/types.js está casi vacío (solo contiene export {};), porque las interfaces y los type alias no existen en tiempo de ejecución.

Si omites el tipo de retorno : number en calculateTotal, la opción isolatedDeclarations reportará un error:

src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.

Este error te beneficia. Si el tipo de retorno se infiere automáticamente, un pequeño cambio en el cuerpo de la función podría cambiar accidentalmente la API pública sin que lo notes.

5. Pruebas de runtime y tipos

Una biblioteca con tipos debe probarse en dos aspectos: que la función retorne el valor correcto, y que los tipos de datos coincidan con lo que prometes. Vitest soporta ambos.

// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";

const goldOrder: Order = {
  id: "ORD-001",
  customer: { id: "C-01", name: "Lan", tier: "gold" },
  items: [
    { sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
    { sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
  ],
};

describe("calculateTotal", () => {
  it("calcula el subtotal antes del descuento", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("aplica 10% de descuento para clientes gold", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("tiene la firma de tipo correcta según lo prometido", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("rechaza niveles de cliente desconocidos", () => {
    // @ts-expect-error "diamond" no es parte de CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

Análisis de cada prueba:

  • Las dos primeras prueban la lógica: 2 × 150.000 + 200.000 = 500.000, después de un 10% de descuento quedan 450.000.
  • expectTypeOf bloquea la firma de la función. Si alguien cambia el tipo de retorno a string, esta prueba fallará.
  • @ts-expect-error afirma que la línea de abajo debe causar un error de tipos. Si algún día "diamond" se vuelve válido, TypeScript reportará que esta directiva sobra.

Ejecuta las pruebas:

npm test
 ✓ src/pricing.test.ts (4 tests) 3ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

Nota: Por defecto, Vitest solo transpila el código sin hacer type-checking. @ts-expect-error y los errores de tipos solo se comprueban cuando ejecutas tsc --noEmit -p tsconfig.test.json (una configuración separada que incluya archivos de test) o vitest --typecheck. Debes incluir este paso en tu CI.

5.1. Verificar el paquete antes de publicar

npm pack --dry-run
npm notice 📦  order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B  dist/index.js
...
npm notice 945B  src/pricing.ts
npm notice 402B  src/types.ts

Lees esta lista para asegurarte de que tienes todos los .d.ts y no hay pruebas ni archivos secretos. La forma más práctica de verificar es ejecutar npm pack, luego instalar el archivo .tgz en un proyecto de prueba e intentar importar. La herramienta @arethetypeswrong/cli (comando attw --pack) también detecta la mayoría de errores de configuración en exports y types.

6. Escribir declarations manualmente para código JavaScript

A veces una biblioteca mantiene un módulo JavaScript antiguo que aún no se convirtió a TypeScript. Puedes escribir manualmente el .d.ts para ese módulo.

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * Suma el IVA al monto.
 * @param amount Monto antes del impuesto (VND).
 * @param rate Tasa de impuesto, por defecto 0.08.
 */
export declare function applyVat(amount: number, rate?: number): number;

Luego lo exportas de index.ts como un módulo normal:

export { applyVat } from "./legacy-tax.js";

Nota: tsc no copia archivos .js y .d.ts escritos manualmente a dist. Tienes dos opciones. La primera es habilitar allowJs: true para que tsc copie legacy-tax.js a dist, luego copia legacy-tax.d.ts con un script, por ejemplo "build": "tsc && cp src/legacy-tax.d.ts dist/". La segunda es habilitar allowJs con JSDoc y dejar que tsc genere .d.ts a partir de JSDoc. Este es un error muy común: funciona localmente, pero después de publicar los usuarios reciben errores de módulo faltante.

Si necesitas añadir tipos a un paquete de terceros que no tiene tipos, usa declare module:

// src/shims/vn-currency.d.ts
declare module "vn-currency" {
  export function format(amount: number): string;
}

Solo debes hacer esto dentro del proyecto. Si la API pública de tu biblioteca depende de este shim, los usuarios no lo verán.

7. Errores comunes

  • types después de default en exports: TypeScript no llega a leer types, así que los usuarios reciben any o el error TS7016.
  • Olvidar la extensión .js al importar con NodeNext: tsc reporta el error TS2835. Si evitas el error cambiando a moduleResolution: "bundler", la biblioteca podría funcionar en bundlers pero romperse con Node puro.
  • Exponer tipos de devDependencies: si Order usa un tipo de un paquete en devDependencies, el archivo .d.ts importará ese paquete. Los usuarios no instalan este paquete así que reciben errores de tipos. Cualquier tipo que aparezca en la API pública debe estar en dependencies o peerDependencies.
  • Depender de skipLibCheck: esta opción hace que tu compilación sea más rápida, pero también oculta errores en los .d.ts que publicas. Prueba con un proyecto de prueba que tenga skipLibCheck deshabilitado.
  • Exportar enums o namespaces libremente: const enum causa problemas a los usuarios con isolatedModules. Una unión de strings como CustomerTier suele ser más simple y segura.
  • Cambiar tipos públicos en un parche: añadir un campo obligatorio a Order es un breaking change, aunque el código JavaScript no cambie una línea. Trata .d.ts como parte de tu API al versionear.

¿Cuándo no necesitas hacer todos estos pasos? Si tu biblioteca debe exportar tanto ESM como CommonJS, usa herramientas de build especializadas como tsup o tsdown en lugar de configurar tsc dos veces. Si el código se usa solo internamente en un monorepo, TypeScript project references suele ser más limpio que publicar a npm.

8. Conclusión

Este artículo construyó la biblioteca order-pricing desde cero, con estos pasos principales:

  • Habilita declaration, declarationMap e isolatedDeclarations en tsconfig.json para generar .d.ts claros y estables.
  • Declara exports en package.json con la condición types primero, y limita los archivos publicados con files.
  • Prueba runtime y tipos con Vitest, expectTypeOf y @ts-expect-error.
  • Escribe .d.ts manualmente para código JavaScript antiguo y recuerda incluirlos en dist.

La regla más importante es tratar los archivos .d.ts como un contrato con tus usuarios. Verifica el paquete real con npm pack antes de cada publicación, porque muchos errores de configuración solo se notan del lado del usuario, no en tu proyecto.

typescriptnpmtype-declarationlibrarytsconfig