É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: truedemande àtscde générer un fichier.d.tsà côté de chaque fichier.js. C’est l’option clé de cet article.declarationMap: truegénère un fichier.d.ts.map. Quand l’utilisateur clique sur « Go to Definition », l’éditeur ouvre le fichier.tssource plutôt que le fichier.d.ts. Pour que cela fonctionne, vous devez aussi publier le dossiersrc.moduleetmoduleResolutionsont définis àNodeNextpour 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.tssans executer le vérificateur de type. Les APIs publiques sont aussi plus claires.excludeexclut les fichiers de test de la compilation, pour qu’ils n’apparaissent pas dansdist.
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.jsdansdistsont traités comme des modules ESM par Node.- Dans
exports, la conditiontypesdoit être en premier. TypeScript parcourt les conditions dans l’ordre et s’arrête à la première qui correspond. Sidefaultest avant, TypeScript ne verra jamaistypes. mainettypesau niveau racine sont une solution de repli pour les anciens outils qui ne lisent pasexports.fileslimite ce qui est envoyé sur npm. Sans ce champ, vous risquez de publiernode_modulesou 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 typedit au compilateur que c’est un import de type uniquement. Cette ligne est complètement supprimée du fichier.jsaprès la compilation.- Le chemin d’import est
./types.jsbien que le fichier source soittypes.ts. AvecNodeNext, c’est obligatoire, car Node cherchera un fichier.jsà l’exécution. DISCOUNT_RATEn’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.
expectTypeOfverrouille la signature de la fonction. Si quelqu’un change le type de retour enstring, ce test échouera.@ts-expect-erroraffirme 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-erroret les erreurs de type sont vérifiées seulement si vous executeztsc --noEmit -p tsconfig.test.json(une config séparée qui inclut les fichiers de test) ouvitest --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 :
tscne copie pas les fichiers.jset.d.tsécrits à la main versdist. Vous avez deux options. La première est d’activerallowJs: truepour quetsccopielegacy-tax.jsversdist, puis copier aussilegacy-tax.d.tsavec un script, par exemple"build": "tsc && cp src/legacy-tax.d.ts dist/". La deuxième est d’activerallowJsavec du JSDoc et laissertscgé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
typesaprèsdefaultdansexports: TypeScript ne lit pastypes, donc l’utilisateur reçoitanyou l’erreur TS7016.- Oubli du suffixe
.jslors des imports avecNodeNext:tscsignale 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: siOrderutilise un type d’un package endevDependencies, le.d.tsimporte 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 dansdependenciesoupeerDependencies. - Compter sur
skipLibCheck: cette option accélère votre build, mais elle cache aussi les erreurs dans vos propres fichiers.d.tspubliés. Testez avec un projet de test en mettantskipLibCheckà false. - Exporter des enum ou namespace de façon arbitraire :
const enumcause des problèmes pour les utilisateurs avecisolatedModules. Une union de chaînes commeCustomerTierest généralement plus simple et sûre. - Changer un type public en version patch : ajouter un champ obligatoire à
Orderest un changement incompatible, même si le code JavaScript n’a pas changé. Traitez le.d.tscomme 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,declarationMapetisolatedDeclarationsdanstsconfig.jsonpour générer des.d.tsclairs et stables. - Déclarez
exportsdanspackage.jsonavec la conditiontypesd’abord, et limitez les fichiers publiés avecfiles. - Testez à la fois le runtime et les types avec Vitest,
expectTypeOfet@ts-expect-error. - Écrivez des
.d.tsmanuels pour du code JavaScript ancien et assurez-vous qu’ils sont dansdist.
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.