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: truele pide atscgenerar archivos.d.tsjunto a cada archivo.js. Esta es la opción central del artículo.declarationMap: truegenera archivos.d.ts.map. Cuando los usuarios hacen clic en “Go to Definition”, el editor abre el archivo.tsoriginal en lugar del.d.ts. Para que esto funcione, debes publicar junto con la carpetasrc.moduleymoduleResolutionse establecen enNodeNextpara 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.tssin ejecutar el type checker. Las APIs públicas también se vuelven más transparentes.excludesaca los archivos de test de la compilación, para que no terminen endist.
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.jsendistcomo ESM.- En
exports, la condicióntypesdebe venir primero. TypeScript recorre las condiciones en orden y se detiene en la primera que coincide. Sidefaultviene primero, TypeScript nunca llegará a leertypes. mainytypesal nivel raíz son un respaldo para herramientas antiguas que no leenexports.fileslimita qué se sube a npm. Sin este campo, es fácil publicar accidentalmentenode_moduleso 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 typele dice al compilador que esto es solo un import de tipos. Esta línea se elimina completamente del archivo.jsdespués de compilar.- La ruta de importación se escribe como
./types.jsaunque el archivo fuente seatypes.ts. ConNodeNext, esta es la forma requerida, porque Node buscará archivos.jsal ejecutar. DISCOUNT_RATEno 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.
expectTypeOfbloquea la firma de la función. Si alguien cambia el tipo de retorno astring, esta prueba fallará.@ts-expect-errorafirma 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-errory los errores de tipos solo se comprueban cuando ejecutastsc --noEmit -p tsconfig.test.json(una configuración separada que incluya archivos de test) ovitest --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:
tscno copia archivos.jsy.d.tsescritos manualmente adist. Tienes dos opciones. La primera es habilitarallowJs: truepara quetsccopielegacy-tax.jsadist, luego copialegacy-tax.d.tscon un script, por ejemplo"build": "tsc && cp src/legacy-tax.d.ts dist/". La segunda es habilitarallowJscon JSDoc y dejar quetscgenere.d.tsa 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
typesdespués dedefaultenexports: TypeScript no llega a leertypes, así que los usuarios recibenanyo el error TS7016.- Olvidar la extensión
.jsal importar conNodeNext:tscreporta el error TS2835. Si evitas el error cambiando amoduleResolution: "bundler", la biblioteca podría funcionar en bundlers pero romperse con Node puro. - Exponer tipos de
devDependencies: siOrderusa un tipo de un paquete endevDependencies, el archivo.d.tsimportará 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 endependenciesopeerDependencies. - Depender de
skipLibCheck: esta opción hace que tu compilación sea más rápida, pero también oculta errores en los.d.tsque publicas. Prueba con un proyecto de prueba que tengaskipLibCheckdeshabilitado. - Exportar enums o namespaces libremente:
const enumcausa problemas a los usuarios conisolatedModules. Una unión de strings comoCustomerTiersuele ser más simple y segura. - Cambiar tipos públicos en un parche: añadir un campo obligatorio a
Orderes un breaking change, aunque el código JavaScript no cambie una línea. Trata.d.tscomo 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,declarationMapeisolatedDeclarationsentsconfig.jsonpara generar.d.tsclaros y estables. - Declara
exportsenpackage.jsoncon la condicióntypesprimero, y limita los archivos publicados confiles. - Prueba runtime y tipos con Vitest,
expectTypeOfy@ts-expect-error. - Escribe
.d.tsmanualmente para código JavaScript antiguo y recuerda incluirlos endist.
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.