🗒️

ESLint を使って JSDoc / TSDoc の記述を必須化する

2023/07/27に公開

これはなに

コードベースに対し JSDoc の記述を必須化するための ESLint 設定手順をまとめたものです。

JSDoc を始めとする Doc コメントはコードに最も近いドキュメントであり、これがあるのと無いのとではコードベースの保守性に天と地ほどの差が生まれます。そんな JSDoc ですが、OSS ならともかく(内製・受託を問わず)商業ソフトウェア開発の現場では軽視されがちです。後からコーディング規約を定めたところで開発メンバーにドキュメントを書く習慣が備わっていなければ書き漏れが頻発するのが関の山です。

コードレビューで都度指摘するにはあまりにコストがかかるため、ESLint に委ねるのが望ましいです。

前提

  • フレームワークは React(or Next.js)を使っている。
  • TypeScript を主体としている。
  • ビルドスクリプトや設定ファイルは JavaScript も併用している。

最終的な設定内容

.eslintrc.js
module.exports = {
  extends: ["plugin:jsdoc/recommended-typescript-error"],
  plugins: ["jsdoc"],
  rules: {
    // Enable
    "jsdoc/check-param-names": [
      "error",
      {
        checkDestructured: false,
      },
    ],
    "jsdoc/check-tag-names": [
      "error",
      {
        definedTags: ["remarks", "typeParam"],
      },
    ],
    "jsdoc/require-description": [
      "error",
      {
        contexts: [
          "ArrowFunctionExpression",
          "ClassDeclaration",
          "ClassExpression",
          "FunctionDeclaration",
          "FunctionExpression",
          "MethodDefinition",
          "PropertyDefinition",
          "VariableDeclaration",
          "TSInterfaceDeclaration",
          "TSTypeAliasDeclaration",
          "TSPropertySignature",
          "TSMethodSignature",
        ],
      },
    ],
    "jsdoc/require-hyphen-before-param-description": ["error", "always"],
    "jsdoc/require-jsdoc": [
      "error",
      {
        publicOnly: true,
        require: {
          ArrowFunctionExpression: true,
          ClassDeclaration: true,
          ClassExpression: true,
          FunctionDeclaration: true,
          FunctionExpression: true,
          MethodDefinition: true,
        },
        contexts: [
          "PropertyDefinition",
          "VariableDeclaration",
          "TSInterfaceDeclaration",
          "TSTypeAliasDeclaration",
          "TSPropertySignature",
          "TSMethodSignature",
        ],
        checkConstructors: false,
      },
    ],
    "jsdoc/require-param": [
      "error",
      {
        checkDestructuredRoots: false,
      },
    ],
    "jsdoc/tag-lines": [
      "error",
      "always",
      {
        startLines: 1,
        applyToEndTag: false,
      },
    ],
    "jsdoc/sort-tags": [
      "error",
      {
        reportIntraTagGroupSpacing: false,
      },
    ],
    // Disable
    "jsdoc/require-returns": ["off"],
  },

  overrides: [
    {
      files: ["*.js", "*.mjs", "*.cjs"],
      rules: {
        "jsdoc/require-param-type": ["error"],
        "jsdoc/require-returns": ["error"],
        "jsdoc/require-returns-type": ["error"],
        "jsdoc/no-types": ["off"],
      },
    },
  ],
};

ESLint プラグインを導入する

https://github.com/gajus/eslint-plugin-jsdoc

以前は ESLint 本体に JSDoc に関するルールが組み込まれていましたが、現在それらは廃止されているため上記のプラグインを導入します。

npm install -D eslint eslint-plugin-jsdoc

eslint-plugin-tsdoc は使用しない

https://github.com/microsoft/tsdoc/tree/main/eslint-plugin

このプラグインは JSDoc が TSDoc の仕様を満たしているかを検証するだけであり、これに頼らず eslint-plugin-jsdoc だけでも TSDoc の検証は十分可能なので見送ります。

ESLint を設定する

プラグインを読み込む

.esliintrc
{
  "plugins": ["jsdoc"],
}

eslint-plugin-jsdoc という名称なので、 plugins フィールドに "jsdoc" を追加します。

ルールプリセット(extends)を読み込む

ベースとなるプリセット(extends)を読み込みます。eslint-plugin-jsdoc には 52 のルールが存在し、うち最大 30 個が推奨ルールとされています。ひとつひとつ自分でルールを吟味して設定してもいいですが、さすがに数が多すぎるので素直にプリセットを利用します。数種類のプリセットが用意されていますが、TypeScript を主体としたコードベースであるのと厳格化したいことから recommended-typescript-error を選択します。

.esliintrc
{
  "extends": ["plugin:jsdoc/recommended-typescript-error"]
}

ひとまずこれで動作するようになったものの、以下の課題が残っています。

As-IS To-Be
エクスポートされていないモジュールも検閲される。 エクスポートされたモジュールのみを検閲対象とし、関数コンポーネント内で定義したコールバック関数といったローカルなものは除外する。
分割代入しているパラメーターがあると、中身の各値ごとに @param の記述を強制される。 省略可能にする。大抵の場合は別途型定義しているため、そちらに JSDoc を記述すればよい。
検閲対象が関数宣言(function)のみとなっている。 関数式(Arrow func)、クラス宣言、変数および型定義も対象に含める。
Type Alias や Interface など TypeScript 固有のモジュールが対象外となっている。 これらも対象に含める。
概要説明が省略可能になっている。 記述必須とする。
戻り値に関する記述が必須となっている。 TypeScript であれば型推論によってある程度補完できるので省略可能にする。ただし JavaScript コードに関しては記述必須とする。
TSDoc 特有のタグが不正とみなされている。 remarks, typeParam など有用なものは使用可能にする。
一切の空行が許容されていない。 可読性を損なうのでタグブロックごとの空行を必須化する。
タグが自由な順序で記述可能となっている。 一貫した順序に統一する。

これらをすべて解消すべく、ここからルールをチューニングしていきます。

エクスポートされたモジュールのみを検閲対象とする

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-jsdoc.md#readme

publicOnly プロパティを true にすることで JSDoc の強制対象をエクスポートしているものに限定します。

.eslintrc
{
  "rules": {
    "jsdoc/require-jsdoc": [
      "error",
      {
        "publicOnly": true,
      },
    ],
  }
}

一方で React コンポーネントの Props 型定義のようにエクスポートしていなくとも JSDoc を必須にしたくなるケースもあります。

エクスポートされていないが、この Props は JSDoc 必須にしたい。
type Props = {
  /** 子要素 */
  children: ReactNode;
  /** 入力欄コントロールの名前 */
  name: string;
};

export const FancyButton = ({ children, name = "" }: Props) => (
  <button name={name}>{children}</button>
);

しかし上記の Props を対象に含めるには publicOnlyfalse にせざるを得ません。そうなると今度は React コンポーネント内のコールバック関数の宣言までも対象となってしまいます。

const App = () => {
  const [count, setCount] = useState(0);

  // この関数にも JSDoc の記述を強制されてしまう。
  const handleClick = () => setCount((count) => count++);

  return <button onClick={handleClick}>{count}</button>;
};

さすがにこれら全てに JSDoc を記述するのは過剰でしょう。どちらを取るか悩ましいですが、今回はこのような internal なものを対象外とし、 Props 型定義への記述は運用でカバーします。

分割代入しているパラメーターの @param を省略可能にする

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-param.md#readme

React コンポーネントの Props は分割代入で受け取ることがセオリーですが、 require-param を有効にしただけだと以下のような記述を要求されます。

As-Is
type Props = {
  /** 子要素 */
  children: ReactNode;
  /** 入力欄コントロールの名前 */
  name: string;
};

/**
 * おしゃれなボタンです。
 *
 * @param root0 FancyButton のプロパティ
 * @param root0.children 子要素
 * @param root0.name 入力欄コントロールの名前
 */
export const FancyButton = ({ children, name = "" }: Props) => (
  <button name={name}>{children}</button>
);

IDE による補完の恩恵[1]を受けるには Props 型を定義している Type Alias 側に JSDoc を記述する必要がありますが、それですと記述内容が重複し冗長です。よって分割代入の場合のみ検閲対象外とします。

.eslintrc
{
  "rules": {
    "jsdoc/require-param": [
      "error",
      {
        "checkDestructuredRoots": false,
      },
    ],
  },
}

これで分割代入している箇所は対象外となります。

To-Be
type Props = {
  /** 子要素 */
  children: ReactNode;
  /** 入力欄コントロールの名前 */
  name: string;
};

/**
 * おしゃれなボタンです。
 */
export const FancyButton = ({ children, name = "" }: Props) => (
  <button name={name}>{children}</button>
);

関数式(Arrow func)、クラス宣言、変数宣言および型定義も検閲対象に含める

デフォルトだと関数宣言(function)しか検閲対象となっていないため、関数式(Arrow func)、クラス宣言および TypeScript の型定義も対象に含めます。

.eslintrc
{
  "rules": {
    "jsdoc/require-jsdoc": [
      "error",
      {
        "publicOnly": true,
+       "require": {
+         "ArrowFunctionExpression": true,
+         "ClassDeclaration": true,
+         "ClassExpression": true,
+         "FunctionDeclaration": true,
+         "FunctionExpression": true,
+         "MethodDefinition": true,
+      },
+      "contexts": [
+        "VariableDeclaration",
+        "TSInterfaceDeclaration",
+        "TSTypeAliasDeclaration",
+        "TSPropertySignature",
+        "TSMethodSignature",
+      ],
+    },
+  ],
  }
}

関数式とクラス宣言は require プロパティでそれぞれ細かく指定できます。

プロパティ名 構文
ArrowFunctionExpression アロー関数式
ClassDeclaration クラス宣言 class Foo { ... }
ClassExpression クラス式 const Foo = class { ... }
FunctionDeclaration 関数宣言 function foo { ... }
FunctionExpression 関数式 const foo = function { ... }
MethodDefinition メソッド定義 const obj = { foo() { ... } }

関数とクラス以外のモジュールに関しては、 contexts プロパティにそれぞれの AST 定義名を追加することで検閲対象に含められます。

構文 AST 定義名
変数宣言 VariableDeclaration
Interface 宣言 TSInterfaceDeclaration
Type Alias 宣言 TSTypeAliasDeclaration
Interface および Type Alias のプロパティ TSPropertySignature
Interface および Type Alias のメソッド定義 TSMethodSignature

AST 定義名は AST Explorer のエディタにコードを貼り付けることで確認できます。

説明文の記述を必須化する

require-jsdoc が強制するのは JSDoc のコメントブロック有無のみであり、その中身までは検閲しません。よって現状では以下の状態でも OK とみなされてしまいます。

これでは意味がない…
/**
 *
 */
export const FancyButton = () => <button>hello world</button>;

よって JSDoc ブロックに説明文の記述を必須化すべく require-description を有効化します。

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description.md#readme

.eslintrc
{
  "rules": {
    "jsdoc/require-description": [
      "error",
      {
        "contexts": [
          "ArrowFunctionExpression",
          "ClassDeclaration",
          "ClassExpression",
          "FunctionDeclaration",
          "FunctionExpression",
          "MethodDefinition",
          "PropertyDefinition",
          "VariableDeclaration",
          "TSInterfaceDeclaration",
          "TSTypeAliasDeclaration",
          "TSPropertySignature",
          "TSMethodSignature",
        ],
      },
    ],
    // ...
  }
}

require-jsdoc 同様、検閲対象とするモジュールを指定します。なお、 "ArrowFunctionExpression", "FunctionDeclaration", "FunctionExpression" はデフォルトで対象となっていますが、ESLint のルール設定はマージではなく上書きとなります。よって設定をカスタマイズする際はこれらも忘れず記述する必要があります。

戻り値に関する記述を省略可能にする

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-returns.md#readme

記述するのが望ましいのは言うまでもないですが、パラメーターと比較すると重要度合いが一枚落ちるのと TypeScript であれば IDE による補助である程度の情報は得られることから省略可能にします。

.eslintrc
{
  "rules": {
    "jsdoc/require-returns": ["off"],
  },
}

ただし、require-returns-check は有効のままにしておくと良いでしょう。こうしておくことで以下のような @returns だけが記述されて放置されるという状態を予防げます。

これなら無い方がマシ
/**
 * Some description
 *
 * @returns
 */

JavaScript コードは必須とする

ビルドスクリプトやツールキットの設定といったファイルは JavaScript で書かれることが多いため、これらに対しては戻り値の記述を必須にします。

overrides
{
  "overrides": [
    {
      "files": ['*.js', '*.mjs', "*.cjs"],
      rules: {
        "jsdoc/require-returns": ["error"],
        "jsdoc/require-returns-type": ["error"],
        "jsdoc/no-types": ["off"],
      },
    }
  ],
}

また、型推論がない JavaScript では JSDoc で型情報を補完するため、これの記述を必須化すべく require-returns-type も併せて有効化しつつ[2]no-types を無効化します。

💥 Bad
/**
 * Some description
 *
 * @returns An extended
 */
👍 Good
/**
 * Some description
 *
 * @returns {boolean} An extended
 */

TSDoc 特有のタグを使用可能にする

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-tag-names.md#readme

不正な JSDoc タグが使用されていないかを検閲するルールですが、デフォルトだと TSDoc 特有のタグは全て不正扱いとなっています。しかし中には有用なタグも存在するので、それらを使用できるようにします。

.eslintrc
{
  "rules": {
     "jsdoc/check-tag-names": [
      "error",
      {
        "definedTags": ["typeParam", "remarks"],
      },
    ],
    // ...
  }
}

definedTags にタグ名を追加することで使用可能となります。今回は typeParamremarks を追加しました。

/**
 * Alias for array
 *
 * @typeParam T - Type of objects the list contains
 */
type List<T> = Array<T>;

/**
 * The summary section should be brief.
 *
 * @remarks
 * The main documentation for an API item is separated into a brief
 * "summary" section optionally followed by an `@remarks` block containing
 * additional details.
 */

タグブロックごとの空行を必須化する

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/tag-lines.md#readme

ルールプリセットでは以下のように一切の空行が許可されていません。

As-Is
/**
 * Some description
 * @param foo param1
 * @param bar param2
 * @return An extended
 * @example
 * ```
 * some(foo, bar);
 * ```
 */

好みによるところもありますが、人によって空行を部分的に入れたり入れなかったりと揺れる可能性が高くなります。それならばシンプルに各タグブロックごとに空行を入れることで統一する方が考慮事項が減ります。

.eslintrc
{
  "rules": {
    "jsdoc/tag-lines": [
      "error",
      "always",
      {
        "startLines": 1,
        "applyToEndTag": false,
      },
    ],
    // ...
  }
}
To-Be
/**
 * Some description
 *
 * @param foo param1
 *
 * @param bar param2
 *
 * @return An extended
 *
 * @example
 * ```
 * some(foo, bar);
 * ```
 */

タグの記述順序を一貫したものに統一する

https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/sort-tags.md#readme

具体的な順序は利用者が自由に設定可能ですが、統一さえされていれば十分なのでデフォルト設定のままにしておきます。ただし、ドキュメントにもあるように tag-lines のオプションに always を指定している場合は reportIntraTagGroupSpacingfalse にする必要があります。

Whether to enable reporting and fixing of line breaks within tags of a given tag group. Defaults to true which will remove any line breaks at the end of such tags. Do not use with true if you are using tag-lines and always.
reportIntraTagGroupSpacing

.eslintrc
{
  "rules": {
    "jsdoc/sort-tags": [
      "error",
      {
        "reportIntraTagGroupSpacing": false,
      },
    ],
  },
}

今回は tag-linesalways にしているので false にします。これをしないと双方のルールが衝突して自動補正時に JSDoc が壊れてしまいます。

締め

エクスポートしていない型定義が一律対象外になってしまっているなど課題はありますが、コードレビューやコーディング規約で十分補完できることでしょう。

JSDoc をはじめドキュメンテーションは慣れていないと執筆が苦痛に感じるものですが、JSDoc が十分に整備されたコードベースは内部実装に潜らずともその関数の用法が理解できるため、特に新人が素早く理解するのに大いに役立ちます。

参考文献

脚注
  1. JSDoc の内容をモジュールの利用側からツールチップで閲覧できる。 ↩︎

  2. TypeScript は tsc の推論で十分賄えることから、ルールプリセットでも無効化されています。 ↩︎

Discussion