Écrire une bibliothèque TypeScript avec déclarations de type

1. Aperçu

Les utilisateurs d’une bibliothèque TypeScript ont besoin de deux choses : du code JavaScript à exécuter et un fichier de déclaration de type (.d.ts) pour que l’éditeur propose des suggestions et le compilateur détecte les erreurs. Sans .d.ts, ou si package.json pointe vers le mauvais fichier, l’utilisateur se retrouve avec du code any partout ou reçoit l’erreur Could not find a declaration file for module.

Dans cet article, nous construirons une petite bibliothèque appelée order-pricing pour calculer le prix total des commandes. À la fin, vous saurez comment configurer tsconfig.json pour générer les .d.ts, déclarer les exports dans package.json, écrire manuellement des déclarations pour du code JavaScript ancien, et tester à la fois le runtime et les types avant de publier.

2. Initialiser le projet

Créez un dossier et installez les outils nécessaires :

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

typescript doit rester dans devDependencies. Les utilisateurs de la bibliothèque n’ont pas besoin du compilateur, ils utiliseront juste les fichiers .js et .d.ts précompilés.

2.1. Configurer 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"]
}

Voici ce que font les options importantes :

  • declaration: true demande à tsc de générer un fichier .d.ts à côté de chaque fichier .js. C’est l’option clé de cet article.
  • declarationMap: true génère un fichier .d.ts.map. Quand l’utilisateur clique sur « Go to Definition », l’éditeur ouvre le fichier .ts source plutôt que le fichier .d.ts. Pour que cela fonctionne, vous devez aussi publier le dossier src.
  • module et moduleResolution sont définis à NodeNext pour que TypeScript résolve les modules exactement comme Node.js. Une bibliothèque compilée de cette manière fonctionne avec les bundlers et Node.
  • isolatedDeclarations (depuis TypeScript 5.5) force chaque API exportée à avoir une déclaration de type explicite, par exemple le type de retour d’une fonction. De cette façon, d’autres outils peuvent générer des .d.ts sans executer le vérificateur de type. Les APIs publiques sont aussi plus claires.
  • exclude exclut les fichiers de test de la compilation, pour qu’ils n’apparaissent pas dans dist.

2.2. Configurer 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"
  }
}

Points à noter :

  • "type": "module" fait que les fichiers .js dans dist sont traités comme des modules ESM par Node.
  • Dans exports, la condition types doit être en premier. TypeScript parcourt les conditions dans l’ordre et s’arrête à la première qui correspond. Si default est avant, TypeScript ne verra jamais types.
  • main et types au niveau racine sont une solution de repli pour les anciens outils qui ne lisent pas exports.
  • files limite ce qui est envoyé sur npm. Sans ce champ, vous risquez de publier node_modules ou des fichiers de configuration internes par accident.

3. Écrire le code de la bibliothèque

3.1. Définir les types de données

// 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 est déclaré comme readonly OrderItem[]. De cette façon, les utilisateurs peuvent passer un tableau normal ou un tableau readonly. La bibliothèque s’engage aussi à ne pas modifier le tableau de l’utilisateur.

3.2. Logique de calcul des prix

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

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

/** Montant total avant remise, en VND. */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** Montant total après application de la remise selon le tier du client. */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

Quelques points dans ce code :

  • import type dit au compilateur que c’est un import de type uniquement. Cette ligne est complètement supprimée du fichier .js après la compilation.
  • Le chemin d’import est ./types.js bien que le fichier source soit types.ts. Avec NodeNext, c’est obligatoire, car Node cherchera un fichier .js à l’exécution.
  • DISCOUNT_RATE n’est pas exporté, donc c’est un détail interne et n’apparaîtra pas dans le .d.ts.
  • Les commentaires au format /** ... */ sont préservés dans le .d.ts. Les utilisateurs les verront au survol dans l’éditeur.

3.3. Point d’entrée (entry point)

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

index.ts détermine l’API publique de la bibliothèque. Exportez aussi les types de données. Sinon, les utilisateurs devront écrire Parameters<typeof calculateTotal>[0] juste pour obtenir le type Order.

4. Compiler et examiner les fichiers .d.ts

npm run build

La structure du dossier dist après la compilation :

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

Contenu de dist/pricing.d.ts :

import type { Order } from "./types.js";
/** Montant total avant remise, en VND. */
export declare function calculateSubtotal(order: Order): number;
/** Montant total après application de la remise selon le tier du client. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

Ce fichier ne contient que les signatures, pas les corps des fonctions. DISCOUNT_RATE n’est pas présent. dist/types.js est presque vide (juste export {};), car les interfaces et les alias de type n’existent pas à l’exécution.

Si vous oubliez le type de retour : number sur calculateTotal, l’option isolatedDeclarations signalera une erreur :

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

Cette erreur vous aide. Si le type de retour est déduit automatiquement, une petite modification dans le corps de la fonction pourrait changer accidentellement l’API publique sans que vous le sachiez.

5. Tester le runtime et les types

Une bibliothèque typée doit être testée de deux façons : la fonction retourne la bonne valeur, et les types correspondent à votre engagement. Vitest supporte les deux.

// 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("calcule le total avant remise", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("applique 10% de remise pour les clients gold", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("a une signature de type conforme à l'engagement", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("refuse un tier de client inconnu", () => {
    // @ts-expect-error "diamond" n'est pas dans CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

Analysons chaque test :

  • Les deux premiers tests vérifient la logique : 2 × 150.000 + 200.000 = 500.000, et après 10% de réduction c’est 450.000.
  • expectTypeOf verrouille la signature de la fonction. Si quelqu’un change le type de retour en string, ce test échouera.
  • @ts-expect-error affirme que la ligne suivante doit causer une erreur de type. Si un jour "diamond" devient valide, TypeScript signalera cette directive comme superflue.

Exécuter les tests :

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

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

Note : Par défaut, Vitest fait juste le transpilage, pas la vérification des types. @ts-expect-error et les erreurs de type sont vérifiées seulement si vous executez tsc --noEmit -p tsconfig.test.json (une config séparée qui inclut les fichiers de test) ou vitest --typecheck. Vous devriez ajouter cette étape à votre CI.

5.1. Vérifier le paquet avant de publier

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

Lisez cette sortie pour vous assurer que tous les .d.ts sont là et qu’aucun fichier de test ou secret n’est présent. Le test le plus réaliste est d’exécuter npm pack, puis d’installer le fichier .tgz résultant dans un projet de test et d’essayer d’importer. L’outil @arethetypeswrong/cli (commande attw --pack) détecte aussi la plupart des erreurs de configuration d’exports et types.

6. Écrire des déclarations manuelles pour du code JavaScript

Parfois, une bibliothèque garde un ancien module JavaScript qui n’a pas encore été converti en TypeScript. Vous pouvez écrire un .d.ts pour ce module.

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * Ajoute la TVA au montant.
 * @param amount Montant avant TVA (VND).
 * @param rate Taux de TVA, par défaut 0.08.
 */
export declare function applyVat(amount: number, rate?: number): number;

Ensuite, réexportez-le depuis index.ts comme un module normal :

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

Note : tsc ne copie pas les fichiers .js et .d.ts écrits à la main vers dist. Vous avez deux options. La première est d’activer allowJs: true pour que tsc copie legacy-tax.js vers dist, puis copier aussi legacy-tax.d.ts avec un script, par exemple "build": "tsc && cp src/legacy-tax.d.ts dist/". La deuxième est d’activer allowJs avec du JSDoc et laisser tsc générer le .d.ts à partir du JSDoc. C’est une erreur très courante : ça fonctionne localement, mais après publication les utilisateurs reçoivent une erreur de module manquant.

Si vous devez ajouter des types pour un package tiers sans types natifs, utilisez declare module :

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

Vous ne devriez faire cela que localement. Si l’API publique de votre bibliothèque dépend de ce shim, les utilisateurs ne le verront pas.

7. Erreurs courantes

  • types après default dans exports : TypeScript ne lit pas types, donc l’utilisateur reçoit any ou l’erreur TS7016.
  • Oubli du suffixe .js lors des imports avec NodeNext : tsc signale l’erreur TS2835. Si vous contournez en passant à moduleResolution: "bundler", la bibliothèque peut marcher dans les bundlers mais échoue avec Node pur.
  • Exposer un type d’une devDependencies : si Order utilise un type d’un package en devDependencies, le .d.ts importe ce package. L’utilisateur ne l’installe pas, donc il reçoit une erreur de type. Tout type qui apparaît dans l’API publique doit être dans dependencies ou peerDependencies.
  • Compter sur skipLibCheck : cette option accélère votre build, mais elle cache aussi les erreurs dans vos propres fichiers .d.ts publiés. Testez avec un projet de test en mettant skipLibCheck à false.
  • Exporter des enum ou namespace de façon arbitraire : const enum cause des problèmes pour les utilisateurs avec isolatedModules. Une union de chaînes comme CustomerTier est généralement plus simple et sûre.
  • Changer un type public en version patch : ajouter un champ obligatoire à Order est un changement incompatible, même si le code JavaScript n’a pas changé. Traitez le .d.ts comme une partie de l’API quand vous numérotez les versions.

Quand ne pas faire tout cela ? Si la bibliothèque doit exporter à la fois ESM et CommonJS, utilisez un outil de build dédié comme tsup ou tsdown plutôt que de configurer tsc deux fois. Si le code est juste interne à un monorepo, les TypeScript project references sont souvent une meilleure solution que de publier sur npm.

8. Conclusion

Cet article a construit la bibliothèque order-pricing depuis zéro, avec ces étapes clés :

  • Activez declaration, declarationMap et isolatedDeclarations dans tsconfig.json pour générer des .d.ts clairs et stables.
  • Déclarez exports dans package.json avec la condition types d’abord, et limitez les fichiers publiés avec files.
  • Testez à la fois le runtime et les types avec Vitest, expectTypeOf et @ts-expect-error.
  • Écrivez des .d.ts manuels pour du code JavaScript ancien et assurez-vous qu’ils sont dans dist.

La règle la plus importante est de traiter le fichier .d.ts comme un contrat avec l’utilisateur. Vérifiez le paquet réellement avec npm pack avant chaque publication, car de nombreuses erreurs de configuration n’apparaissent que du côté de l’utilisateur, pas dans votre projet.

typescriptnpmtype-declarationlibrarytsconfig