TypeScriptライブラリにtype declarationを作成する

1. 概要

TypeScriptライブラリのユーザーは2つのものが必要です: 実行するJavaScriptコードとeditorが補完でき、compilerがエラーを検出するtype declaration (.d.ts) ファイル。.d.tsが足りない、またはpackage.jsonが間違ったファイルを指している場合、ユーザーはanyが至る所に現れたり、Could not find a declaration file for moduleエラーを受け取ったりします。

この記事では、注文金額を計算する小さなライブラリorder-pricingを構築します。この記事を読むと、.d.tsを生成するためのtsconfig.jsonの設定方法、package.jsonでexportsを宣言する方法、古いJavaScriptコードのための手動でdeclarationを書く方法、およびpublishする前にruntimeと型の両方をテストする方法を知ることができます。

2. プロジェクトの初期化

フォルダを作成し、必要なツールをインストールします:

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

typescriptはdevDependenciesにのみ必要です。ライブラリのユーザーはcompilerを必要とせず、すでにビルドされた.jsと.d.tsファイルだけを使用します。

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

重要なオプションの意味:

  • declaration: trueは、tscが各.jsファイルと共に.d.tsファイルを生成することを要求します。これはこの記事の中核となるオプションです。
  • declarationMap: trueは.d.ts.mapファイルを生成します。ユーザーが“Go to Definition”をクリックすると、editorは.d.tsファイルの代わりに元の.tsファイルを開きます。この機能を機能させるには、srcディレクトリと一緒にpublishする必要があります。
  • moduleとmoduleResolutionをNodeNextに設定すると、TypeScriptはNode.jsと同じ方法でmoduleを解決します。このようにビルドされたライブラリはbundlerとNode.jsの両方で使用できます。
  • isolatedDeclarations(TypeScript 5.5以降で利用可能)では、exportされるすべてのAPIが明確に型を宣言する必要があります。たとえば、関数の戻り値の型など。これにより、他のツールはtype checkerを実行せずに.d.tsを生成できます。public APIもより明示的になります。
  • excludeはテストファイルをビルドから除外し、テストがdistに入らないようにします。

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

注意すべき点:

  • "type": "module"により、Node.jsはdist内の.jsファイルをESMとして認識します。
  • exports内では、types条件が最初に来る必要があります。TypeScriptは条件を順番に参照し、最初にマッチした条件で停止します。defaultが前にあると、TypeScriptはtypesに到達しません。
  • ルートレベルのmainとtypesは、exportsを読まない古いツールのためのフォールバックです。
  • filesはnpmにアップロードされるものを制限します。このフィールドがないと、誤ってnode_modulesや内部設定ファイルをpublishしてしまう可能性があります。

3. ライブラリコードの記述

3.1. データ型の定義

// 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はreadonly OrderItem[]として宣言されています。これにより、ユーザーは通常の配列とreadonly配列の両方を渡すことができます。ライブラリはまた、ユーザーの配列を変更しないことを約束します。

3.2. 料金計算ロジック

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

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

/** 割引前の商品合計、単位はVND */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** 顧客ランクに基づいて割引を適用した合計金額 */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

上記のコード内で:

  • import typeはcompilerに、これが型importのみであることを伝えます。このライン(行)はビルド後の.jsファイルから完全に削除されます。
  • importパスは、ソースファイルがtypes.tsであっても./types.jsとして記述されます。NodeNextでは、Node.jsが実行時に.jsファイルを探すため、これは必須の記述方法です。
  • DISCOUNT_RATEはexportされていないため、内部の詳細であり、.d.tsには表示されません。
  • /** ... */形式のコメントは.d.tsに保持されます。ユーザーはeditorでホバーしたときにこのコンテンツを見ることができます。

3.3. エントリーポイント

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

index.tsはライブラリのpublic APIを決定します。データ型もexportする必要があります。そうしないと、ユーザーがOrder型を取得するためにParameters<typeof calculateTotal>[0]を書かなければなりません。

4. ビルドと.d.tsファイルの確認

npm run build

ビルド後のdistディレクトリの構造:

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

dist/pricing.d.tsの内容:

import type { Order } from "./types.js";
/** 割引前の商品合計、単位はVND */
export declare function calculateSubtotal(order: Order): number;
/** 顧客ランクに基づいて割引を適用した合計金額 */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

このファイルは署名(signature)のみを含み、関数の本体は含みません。DISCOUNT_RATEも存在しません。dist/types.jsはほぼ空です(export {};のみ)。interfaceとtype aliasはruntimeに存在しないため。

calculateTotalの戻り値の型: numberを削除すると、isolatedDeclarationsオプションがエラーを報告します:

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

このエラーはあなたにとって有利です。戻り値の型が自動的に推論される場合、関数の本体の小さな変更が意図せずにpublic APIを変更する可能性があります。

5. runtimeと型のテスト

型付きライブラリは2つの面でテストする必要があります: 関数が正しい値を返すこと、および型が約束どおりであること。Vitestはその両方をサポートしています。

// 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("割引前の合計を計算する", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("ゴールド会員には10%割引を適用", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("約束通りの正しい型シグネチャを持っている", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("見知らぬ顧客ランクは受け入れない", () => {
    // @ts-expect-error "diamond"はCustomerTierに含まれない
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

各テストの分析:

  • 最初の2つのテストはロジックを検証します: 2 × 150,000 + 200,000 = 500,000、10%割引後は450,000。
  • expectTypeOfは関数の署名をロックします。その後誰かが戻り値の型をstringに変更した場合、このテストは失敗します。
  • @ts-expect-errorは、下の行が必ず型エラーを引き起こすことを確認します。ある日"diamond"が有効になった場合、TypeScriptはこのdirectiveが不要であることを報告します。

テストを実行:

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

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

注意: デフォルトでは、Vitestはコードをtranspileするだけでtype-checkしません。@ts-expect-errorと型エラーは、tsc --noEmit -p tsconfig.test.json(テストファイルを含む別の設定)またはvitest --typecheckを実行したときのみチェックされます。このステップをCIに含めることをお勧めします。

5.1. publishする前にパッケージをチェック

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

このリストを読んで、すべての.d.tsがあり、テストファイルや秘密のファイルがないことを確認してください。最も実用的なチェック方法は、npm packを実行してから.tgzファイルをサンプルプロジェクトにインストールしてimportを試すことです。@arethetypeswrong/cliツール(attw --packコマンド)は、exportsとtypesの設定エラーのほとんどを検出することもできます。

6. JavaScriptコードの手動declarationの記述

ライブラリがまだTypeScriptに変換されていない古いJavaScriptモジュールを保持している場合があります。そのモジュールの.d.tsを手動で記述できます。

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * 税金を金額に加算します。
 * @param amount 税金前の金額(VND)。
 * @param rate 税率、デフォルトは0.08。
 */
export declare function applyVat(amount: number, rate?: number): number;

その後、index.tsから通常のmoduleとしてexportし直します:

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

注意: tscは手動で記述した.jsと.d.tsファイルをdistにコピーしません。2つの選択肢があります。最初の方法は、tscがlegacy-tax.jsをdistにコピーするためにallowJs: trueを有効にし、次にスクリプト(例:"build": "tsc && cp src/legacy-tax.d.ts dist/")でlegacy-tax.d.tsをコピーすることです。2番目の方法は、allowJsをJSDocと一緒に有効にして、tscがJSDocから.d.tsを自動生成するようにすることです。これは非常に一般的なエラーです: ローカルで実行しても問題ありませんが、publish後ユーザーはモジュール不足エラーを受け取ります。

型を持たないサードパーティパッケージの型を補足する必要がある場合は、declare moduleを使用してください:

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

これはプロジェクト内でのみ行うべきです。ライブラリのpublic APIがこのshimに依存している場合、ユーザーはそれを見ることができません。

7. よくあるエラー

  • exports内のdefaultの後ろにtypesがある: TypeScriptはtypesに到達しないため、ユーザーはanyまたはTS7016エラーを受け取ります。
  • NodeNextでのimportに.js拡張子を忘れる: tscはTS2835エラーを報告します。moduleResolution: "bundler"に変更してこのエラーを回避した場合、ライブラリはbundlerで実行される場合がありますが、Node.jsで破損します。
  • devDependenciesから型を公開する: OrderがdevDependencies内のパッケージの型を使用している場合、.d.tsファイルはそのパッケージをimportします。ユーザーはこのパッケージをインストールしないため、型エラーが発生します。public APIに表示される型のパッケージはdependenciesまたはpeerDependenciesに含まれる必要があります。
  • skipLibCheckに依存する: このオプションはビルドを高速化しますが、publishした.d.tsファイル自体のエラーも隠します。skipLibCheckを無効にしたサンプルプロジェクトでテストしてください。
  • enumやnamespaceを無分別にexportする: const enumはisolatedModulesを有効にしたユーザーに問題を起こします。CustomerTierのようなunion stringの方が通常はシンプルで安全です。
  • パッチバージョンで公開型を変更する: Orderに必須フィールドを追加することはbreaking changeです。JavaScriptコードが1行も変更されなくてもです。versioningするときは.d.tsをAPIの一部と見なしてください。

すべての上記のステップを自分で行う必要がない場合はいつですか?ライブラリがESMとCommonJSの両方をexportする必要がある場合は、tscを2回設定する代わりに、tsupやtsdownなどの専用ビルドツールを使用してください。コードが単にmonorepo内で内部的に使用されている場合、TypeScript project referencesはnpmにpublishするよりもシンプルな選択肢になることが多いです。

8. 結論

この記事はorder-pricingライブラリをゼロから構築しました。主なステップは次のとおりです:

  • tsconfig.jsonでdeclaration、declarationMap、isolatedDeclarationsを有効にして、明確で安定した.d.tsを生成します。
  • package.jsonでexportsを宣言し、types条件を最初に配置し、filesでpublishファイルを制限します。
  • Vitest、expectTypeOf、@ts-expect-errorを使用してruntimeと型の両方をテストします。
  • 古いJavaScriptコード用に.d.tsを手動で記述し、distに含めることを忘れないでください。

最も重要なルールは、.d.tsファイルをユーザーとの契約と見なすことです。多くの設定エラーはユーザー側でのみ表示され、プロジェクト内では表示されないため、publish前にnpm packでパッケージを実際にチェックしてください。

typescriptnpmtype-declarationlibrarytsconfig