TypeScript-Bibliothek mit Typdeklarationen schreiben

1. Übersicht

Nutzer einer TypeScript-Bibliothek brauchen zwei Dinge: JavaScript-Code zum Ausführen und eine Type-Declaration-Datei (.d.ts), damit der Editor Vorschläge machen und der Compiler Fehler fangen kann. Falls .d.ts fehlt oder package.json auf die falsche Datei zeigt, bekommen Nutzer überall any oder den Fehler Could not find a declaration file for module.

In diesem Artikel bauen wir eine kleine Bibliothek namens order-pricing, die Bestellkosten berechnet. Hinterher wissen Sie, wie man tsconfig.json konfiguriert, um .d.ts zu generieren, exports in package.json deklariert, Type-Deklarationen manuell für alten JavaScript-Code schreibt und sowohl Runtime als auch Types vor dem Veröffentlichen testet.

2. Projekt initialisieren

Sie erstellen ein Verzeichnis und installieren die benötigten Tools:

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

typescript gehört nur in devDependencies. Nutzer der Bibliothek brauchen den Compiler nicht, sie nutzen nur die bereits erstellten .js und .d.ts Dateien.

2.1. tsconfig.json konfigurieren

{
  "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"]
}

Was die wichtigsten Optionen bedeuten:

  • declaration: true weist tsc an, neben jeder .js Datei auch eine .d.ts Datei zu generieren. Das ist die Kernfunktion dieses Artikels.
  • declarationMap: true generiert .d.ts.map Dateien. Wenn ein Nutzer “Go to Definition” klickt, öffnet der Editor die ursprüngliche .ts Datei statt der .d.ts Datei. Damit das funktioniert, müssen Sie das src Verzeichnis zusammen mit der Bibliothek veröffentlichen.
  • module und moduleResolution auf NodeNext setzen, damit TypeScript Module genauso auflöst wie Node.js. Eine Bibliothek, die so gebaut wird, funktioniert in Bundlern und Node.js gleichermaßen.
  • isolatedDeclarations (seit TypeScript 5.5) erzwingt, dass alle exportierten APIs eindeutig getypt werden, z.B. der Rückgabetyp einer Funktion. Dadurch können andere Tools .d.ts generieren, ohne den Type Checker auszuführen. Öffentliche APIs werden auch transparenter.
  • exclude schließt Test-Dateien aus dem Build aus, damit sie nicht in dist landen.

2.2. package.json konfigurieren

{
  "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"
  }
}

Wichtige Punkte:

  • "type": "module" lässt Node die .js Dateien in dist als ESM interpretieren.
  • Im exports Feld muss die Bedingung types zuerst stehen. TypeScript durchläuft die Bedingungen der Reihe nach und stoppt bei der ersten Übereinstimmung. Steht default zuerst, erreicht TypeScript types nie.
  • main und types auf oberster Ebene sind ein Fallback für ältere Tools, die exports nicht lesen.
  • files begrenzt, was auf npm hochgeladen wird. Ohne dieses Feld veröffentlichen Sie versehentlich node_modules oder interne Konfigurationsdateien.

3. Bibliotheks-Code schreiben

3.1. Datentypen definieren

// 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 ist als readonly OrderItem[] deklariert. Dadurch können Nutzer normale Arrays und readonly Arrays übergeben. Die Bibliothek verpflichtet sich auch, das Array des Nutzers nicht zu verändern.

3.2. Preislogik

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

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

/** Gesamtbetrag vor Rabatt, Einheit VND. */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** Gesamtbetrag nach Anwendung des Rabatts nach Kundenstatus. */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

Im obigen Code:

  • import type teilt dem Compiler mit, dass dies nur ein Type Import ist. Diese Zeile wird nach dem Build vollständig aus der .js Datei entfernt.
  • Der Import-Pfad ist ./types.js, obwohl die Quelle types.ts ist. Mit NodeNext ist dies erforderlich, da Node bei der Ausführung nach .js Dateien sucht.
  • DISCOUNT_RATE wird nicht exportiert, es ist also ein internes Detail und erscheint nicht in .d.ts.
  • Kommentare im /** ... */ Format bleiben in .d.ts erhalten. Nutzer sehen diesen Text beim Hovern im Editor.

3.3. Einstiegspunkt

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

index.ts definiert die öffentliche API der Bibliothek. Sie sollten auch die Datentypen exportieren. Sonst müssten Nutzer Parameters<typeof calculateTotal>[0] schreiben, nur um den Order Typ zu bekommen.

4. Build und .d.ts Dateien ansehen

npm run build

Die Struktur des dist Verzeichnisses nach dem Build:

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

Inhalt von dist/pricing.d.ts:

import type { Order } from "./types.js";
/** Gesamtbetrag vor Rabatt, Einheit VND. */
export declare function calculateSubtotal(order: Order): number;
/** Gesamtbetrag nach Anwendung des Rabatts nach Kundenstatus. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

Diese Datei enthält nur Signaturen, keine Funktionskörper. DISCOUNT_RATE ist auch nicht vorhanden. dist/types.js ist fast leer (nur export {};), da Interfaces und Type Aliases nicht zur Runtime existieren.

Falls Sie den Rückgabetyp : number bei calculateTotal vergessen, wird die isolatedDeclarations Option einen Fehler melden:

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

Dieser Fehler ist hilfreich. Wenn der Rückgabetyp automatisch abgeleitet wird, könnte eine kleine Änderung im Funktionskörper versehentlich die öffentliche API verändern, ohne dass Sie es bemerken.

5. Runtime und Types testen

Eine Bibliothek mit Types muss auf zwei Ebenen getestet werden: Die Funktion gibt den richtigen Wert zurück, und die Datentypen sind wie versprochen. Vitest unterstützt beides.

// 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("berechnet Gesamtbetrag vor Rabatt", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("gewährt Gold-Kunden 10% Rabatt", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("hat die richtige Typ-Signatur wie versprochen", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("lehnt ungültige Kundenklassifizierungen ab", () => {
    // @ts-expect-error "diamond" ist nicht in CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

Analyse jedes Tests:

  • Die ersten beiden Tests prüfen die Logik: 2 × 150.000 + 200.000 = 500.000, nach 10% Rabatt 450.000.
  • expectTypeOf sperrt die Funktions-Signatur. Wenn jemand später den Rückgabetyp zu string ändert, schlägt dieser Test fehl.
  • @ts-expect-error behauptet, dass die folgende Zeile einen Type-Fehler verursachen muss. Falls eines Tages "diamond" gültig wird, wird TypeScript dieser Direktive einen Fehler melden.

Tests ausführen:

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

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

Hinweis: Vitest transpiliert standardmäßig nur Code, führt aber keine Type-Überprüfung durch. @ts-expect-error und Type-Fehler werden nur geprüft, wenn Sie tsc --noEmit -p tsconfig.test.json ausführen (eine separate Konfiguration mit Test-Dateien) oder vitest --typecheck. Sie sollten diesen Schritt in Ihre CI aufnehmen.

5.1. Paket vor Veröffentlichung prüfen

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

Lesen Sie diese Liste, um sicherzustellen, dass alle .d.ts Dateien vorhanden sind und keine Test-Dateien oder Geheimnisse enthalten sind. Der realistischste Weg zu testen ist, npm pack auszuführen, die .tgz Datei in ein Testprojekt zu installieren und zu importieren. Das Tool @arethetypeswrong/cli (Befehl attw --pack) findet auch die meisten exports und types Konfigurationsfehler.

6. Type-Deklarationen manuell für JavaScript-Code schreiben

Manchmal enthält eine Bibliothek noch alten JavaScript-Code, der nicht zu TypeScript konvertiert wurde. Sie können dafür manuell .d.ts schreiben.

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * Fügt Mehrwertsteuer zum Betrag hinzu.
 * @param amount Betrag vor Steuern (VND).
 * @param rate Steuersatz, Standard 0.08.
 */
export declare function applyVat(amount: number, rate?: number): number;

Dann exportieren Sie es aus index.ts wie ein normales Modul:

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

Hinweis: tsc kopiert nicht automatisch handgeschriebene .js und .d.ts Dateien in dist. Sie haben zwei Optionen. Die erste ist, allowJs: true zu aktivieren, damit tsc die legacy-tax.js in dist kopiert, und dann legacy-tax.d.ts mit einem Script zu kopieren, z.B. "build": "tsc && cp src/legacy-tax.d.ts dist/". Die zweite Option ist, allowJs mit JSDoc zu aktivieren und tsc die .d.ts aus JSDoc selbst generieren zu lassen. Dies ist ein häufiger Fehler: lokal funktioniert alles, aber nach der Veröffentlichung sagen Nutzer, dass das Modul fehlt.

Falls Sie einen Type für ein Drittanbieter-Package schreiben müssen, das keine Types hat, verwenden Sie declare module:

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

Sie sollten dies nur im Projekt selbst tun. Falls die öffentliche API Ihrer Bibliothek auf diesem Shim angewiesen ist, sehen Nutzer ihn nicht.

7. Häufige Fehler

  • types steht nach default im exports Feld: TypeScript liest types nie, Nutzer bekommen any oder TS7016 Fehler.
  • Vergessen der .js Erweiterung bei Imports mit NodeNext: tsc meldet TS2835 Fehler. Wenn Sie das vermeiden, indem Sie zu moduleResolution: "bundler" wechseln, funktioniert die Bibliothek vielleicht in Bundlern, aber bricht mit reinem Node.
  • Types aus devDependencies exportieren: Falls Order einen Type von einem Package in devDependencies verwendet, wird .d.ts dieses Package importieren. Nutzer installieren es nicht, daher bekommen Sie Type-Fehler. Alle Types in der öffentlichen API müssen in dependencies oder peerDependencies sein.
  • Sich auf skipLibCheck verlassen: Diese Option macht Ihren Build schneller, verbirgt aber auch Fehler in den .d.ts Dateien, die Sie veröffentlichen. Testen Sie mit einem Projekt bei skipLibCheck: false.
  • enum oder namespace beliebig exportieren: const enum macht Probleme für Nutzer mit isolatedModules. Union-Strings wie CustomerTier sind normalerweise einfacher und sicherer.
  • Den öffentlichen Typ in einer Patch-Version ändern: Ein neues erforderliches Feld zu Order hinzufügen ist eine Breaking Change, auch wenn keine JavaScript-Zeile sich ändert. Behandeln Sie .d.ts als Teil Ihrer API bei der Versionierung.

Wann brauchen Sie nicht all das selbst zu tun? Falls Ihre Bibliothek sowohl ESM als auch CommonJS exportieren muss, verwenden Sie ein spezialisiertes Build-Tool wie tsup oder tsdown, statt tsc zweimal zu konfigurieren. Falls der Code nur intern in einem Monorepo verwendet wird, sind TypeScript project references normalerweise einfacher als die Veröffentlichung auf npm.

8. Fazit

Dieser Artikel hat die order-pricing Bibliothek von Grund auf gebaut. Hier sind die wichtigsten Schritte:

  • Aktivieren Sie declaration, declarationMap und isolatedDeclarations in tsconfig.json, um klare und stabile .d.ts zu generieren.
  • Deklarieren Sie exports in package.json mit types an erster Stelle, und begrenzen Sie die veröffentlichten Dateien mit files.
  • Testen Sie sowohl Runtime als auch Types mit Vitest, expectTypeOf und @ts-expect-error.
  • Schreiben Sie .d.ts manuell für alten JavaScript-Code und bringen Sie sie in dist.

Die wichtigste Regel ist, .d.ts Dateien als Vertrag mit Nutzern zu sehen. Prüfen Sie das Paket mit npm pack vor jeder Veröffentlichung, da viele Konfigurationsfehler nur auf der Nutzer-Seite sichtbar werden, nicht in Ihrem Projekt.

typescriptnpmtype-declarationlibrarytsconfig