内的品質を無視せざるを得ない状況に陥るな

ソフトウェアの品質

ソフトウェアの品質といえば, 大まかに外的品質と内的品質に分けられます.

  • 外的品質: ユーザーから見た品質
    • 例: 安全かつ確実に動作すること, 操作しやすいこと
  • 内的品質: 開発者から見た品質
    • 例: 変更しやすいこと, 型・linter・テストなどに守られていること, 読みやすいこと

当然ですが, ソフトウェア開発者としては外的・内的どちらの品質も高い, という状態を目指すべきでしょう. 場合によって優先度こそあれ, どちらかを一方的に切り捨てるべきではありません.

どちらの品質から高めるか

ある程度の複雑さを持ったソフトウェア (のコンポーネント) を作る場合, 大抵はどちらの品質も高い状態に向かって一直線に進めることはなくて, まずはどちらか一方の品質が高い状態を目指すのが普通かなと思います.

外的品質と内的品質の四象限図. 外的品質と内的品質の両方が低い現在地から, 両方が高い目的地に向かって, 先に外的品質と内的品質のいずれかを高める二つのルートがある

こういった開発の進め方については様々な流派があると思いますが, ここではどれが最適だという話はしません. どちらも状況によっては合理的な選択となり得ます.

リファクタリングの限界

ところでどちらの品質を先に高めるとしても, 片方の品質を高めた後に, もう片方の品質もその延長線上で高められるということが, 選択における暗黙の前提となっています.

実際にはこの前提は一般には成り立たちません. この世の複雑なものには一般に局所最適解というものが存在し得て, 例えば先に外的品質を十分に高めることはできたとしても, そこから内的品質を高める方向にリファクタリングすることは極めて難しいということがしばしば起こります. こうなってしまうと両方の品質が高い状態を目指すには, まずは局所最適解から脱出した上で, 改めて別の方向から目的地を目指す (言い換えると, 作り直す) ことになり, 二度手間になってしまいます.

外的品質と内的品質の四象限図. 外的品質のみが高い現在地から, 外的品質と内的品質の両方が高い目的地まで, 直線的なルートでは辿り着けず, 迂回したルートを通る必要がある

もちろん逆に内的品質を先に高めていたのに, そこから外的品質を高めることができないということも起こり得ます.

内的品質は無視されがち

外的品質と内的品質の大きな違いとして, 外的品質が低い状態は無視しづらいのに対して, 内的品質が低い状態は無視しようとすればできてしまうということがあります.

もし外的品質が低いと, そもそもソフトウェアをユーザーに提供することができなかったり, できたとしてもユーザーから全く使ってもらえなかったりします. こうした状況は致命的で, 無視できるとしたら開発自体を中止するときなどに限られるでしょう.

一方で内的品質が低かったとしても, 直ちにはそこまで致命的な状況になりません. 型エラーや linter のエラー, テストの失敗やそもそもそういった検査がないことなどは, ほとんどは技術的には無視することが可能です. もちろん長期的には設計などに負債が溜まって開発を中断せざるを得ないところまで行くかもしれませんが, 短期的には無視してもあまり大きな問題にはなりません.

それゆえ, 先に外的品質を高めたものの内的品質を高めることが難しかった場合には, ほぼ確実に内的品質は無視され, そのまま放置されてしまいます.

我々はどうすべきか

ここで考えるべきことは, 単純にどちらの品質を先に高めると良いかではなく, どちらの品質も高めるためにはどのようなルートで進むと良いかです. そもそも「先に外的品質を高めたものの内的品質を高めることが難しかった」といった状況が発生しないのが理想的なはずですよね.

一つはあらかじめリスクの高い箇所を考えて, その方向から進めるという作戦をとることです. 例えば外的品質を高められることは確実だが, そこから内的品質を高められるかが不確実であるといった場合は, 先に内的品質を高めることから始めます. 逆に後から外的品質を高められるかどうかが不確実なら, 先に外的品質を高めます. このように進めれば, もう片方の品質が高められないということが後になってから判明するリスクが下がり, どちらの品質も高い状態に辿り着く蓋然性が上がるはずです.

もう一つは小さく失敗するということです. とにかく先に片方の品質のみを高めて全体を完成させようとするのは, 局所最適解に陥るリスクを大幅に上げます. ソフトウェアを小さい単位に分割して, その細かい単位ごとに両方の品質を上げることを目指せば, 仮に一つ失敗したとしても全体を作り直すようなことにはならず, 総合的なリスクは小さくなります.

おまけ (一般論ではない話)

ところで私が遭遇するほとんどのケースでは, ソフトウェアに求められる要件は典型的で, 外的品質を高めることにそこまでの不確実性がありません (もし外的品質を高めることに不確実性があるような面白い話があったら呼んでほしい). また隣 (ネットワーク上) で開発しているメンバーを見ても, 外的品質を高める実装に苦戦している状況よりは, 設計や型, テストなど内的品質に関わる技術に苦戦している状況の方が多く見られるように思います. そのため, 少なくとも私の周りでは, 典型的には内的品質を先に高める方が品質が安定するだろうと考えています.

ESLint の共有設定を Flat Config に対応させる

まえがき

こんにちは, 人間 ESLint です.

そんな人間 ESLint の私ですが, 私一人があらゆるコードに注意深く目を通して, さらに修正案まで提示するというのは大変です. 多くの問題は機械的に検知できるはずなので, そういった仕事を私の代わりにしてくれるメカ人間 ESLint が欲しくなってきます.

そんなわけで, 4 年ほど前から ESLint の共有設定を仕込み続けています. この共有設定を作るために, 私自身も ESLint の組み込みのルールtypescript-eslint が提供するルールを何度か全て見直したりしていて, それゆえの人間 ESLint でもあります.

github.com

Flat Config

ところで ESLint の設定ファイルは, 次のメジャーバージョンである v9 以降に新しい形式のもの (通称「Flat Config」) が標準となり, これまでの形式のもの (eslintrc) は非推奨とすることが計画されています. 現在の最新メジャーバージョンである v8 でも, すでに Flat Config の利用自体は可能となっています.

eslint.org

詳しい変更点としては上記のドキュメントや移行ガイドを読んでいただくとして, 例えば eslintrc での以下のような設定は,

"use strict";

module.exports = {
  // (余談: はたして overrides 以外を使うことがあるだろうか?)
  overrides: [
    // ソースファイル (TypeScript) 用の設定
    {
      // *.ts ファイルに対して,
      files: ["*.ts"],
      // 共有設定を拡張し,
      extends: [
        "@susisu/eslint-config/preset/ts",
        "prettier",
      ],
      // プロジェクトごとの設定を追加する.
      parserOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        project: "./tsconfig.json",
      },
      env: {
        es2021: true,
      },
    },
    // その他のファイル用の設定...
  ],
};

Flat Config では以下のような設定に置き換えられます. 大きな変更点として共有設定やプラグインなどのインポートが必要になったのもありますが, なにより extends による拡張 (入れ子構造) がなくなり, Flat Config という名前のとおり平坦な構造になっていることがわかります.

"use strict";

const { config } = require("@susisu/eslint-config");
const eslintConfigPrettier = require("eslint-config-prettier");
const globals = require("globals");

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // 共有設定を使用する.
    ...config.tsTypeChecked,
  },
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // 共有設定を使用する.
    ...eslintConfigPrettier,
  },
  {
    // *.ts ファイルに対して,
    files: ["**/*.ts"],
    // プロジェクトごとの設定を使用する.
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      parserOptions: {
        project: "./tsconfig.json",
      },
      globals: globals.es2021,
    },
  },
  // その他のファイル用の設定...
];

あるファイルに対して最終的に適用される設定は, 配列に含まれる設定のうち, filesignores がそのファイルにマッチしたものを, 上から順番に deep merge したものです. 簡単ですね.

共有設定をどのように使うべきか

というわけで早速 Flat Config に移行していきたいわけですが, 上記の設定例を見ると files の重複が目立ちます. これらは記述としても煩雑ですし, 意味的にも全て同じであってほしいので, 重複して書かれているのは好ましくありません.

解決案 1. 共通部分を分離する

重複している files の部分を別途定義して分離することで, 意味的な繰り返しは回避できます.

const forSourceFiles = {
  files: ["**/*.ts"],
};

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // 共有設定を使用する.
    ...config.tsTypeChecked,
  },
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // 共有設定を使用する.
    ...eslintConfigPrettier,
  },
  {
    // ソースファイルに対して,
    ...forSourceFiles,
    // プロジェクトごとの設定を使用する.
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      parserOptions: {
        project: "./tsconfig.json",
      },
      globals: globals.es2021,
    },
  },
  // その他のファイル用の設定...
];

ただし記述は引き続き繰り返しがあって煩雑なままですし, 別の変数として分離されていることで設定がやや読み取りづらくなっているような気もします.

解決案 2. さらに map で重複を除く

解決案 1 の同じ記述の繰り返しについては, 配列に対して map を使うことで取り除けます

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...[
    // 共有設定を拡張し,
    config.tsTypeChecked,
    eslintConfigPrettier,
    // プロジェクトごとの設定を追加する.
    {
      languageOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        parserOptions: {
          project: "./tsconfig.json",
        },
        globals: globals.es2021,
      },
    },
  ].map((config) => ({
    // *.ts ファイルに対して.
    files: ["**/*.ts"],
    ...config,
  })),
  // その他のファイル用の設定...
];

これで繰り返しはなくなりましたが, map の都合によって記述の順番が逆になり, files が最後に書かれるようになってしまいました. あまり気にならない人もいるかもしれませんが, 私はこの場合は files が最初に書かれていた方がわかりやすいと思います.

解決案 3. さらにユーティリティ関数を定義する

解決案 2 の問題を解消するために, 記述の順番を反転させるユーティリティ関数を用意します.

function map(base, configs) {
  return configs.map((config) => ({ ...base, ...config }));
}

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...map(
    {
      // *.ts ファイルに対して.
      files: ["**/*.ts"],
    },
    [
      // 共有設定を拡張し,
      config.tsTypeChecked,
      eslintConfigPrettier,
      // プロジェクトごとの設定を追加する.
      {
        languageOptions: {
          ecmaVersion: "latest",
          sourceType: "module",
          parserOptions: {
            project: "./tsconfig.json",
          },
          globals: globals.es2021,
        },
      },
    ],
  ),
  // その他のファイル用の設定...
];

これで記述の順番もわかりやすくなりましたが, 今度はユーティリティ関数を誰が用意するのか, という問題が生まれています. プロジェクトごとに定義したり別途ライブラリへの依存を追加するのは面倒ですし, 共有設定ごとに似たような関数が提供されるのもなんだか微妙そうです.

解決案 4. 設定項目を deep merge する

ここまでの案とは方向性が異なり, 旧来の eslintrc に存在した extends を再現するようなアイデアです.

複数の共有設定が languageOptionsrules といった項目を設定するため, 単純な shallow merge では上手くいきません. そのため (なんらかライブラリを使うなどして) deep merge をする必要があります.

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  deepMerge(
    // *.ts ファイルに対して,
    {
      files: ["**/*.ts"],
    },
    // 共有設定を拡張し,
    config.tsTypeChecked,
    eslintConfigPrettier,
    // プロジェクトごとの設定を追加する.
    {
      languageOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
        parserOptions: {
          project: "./tsconfig.json",
        },
        globals: globals.es2021,
      },
    },
  ),
  // その他のファイル用の設定...
];

たしかに重複はなくなりましたが, 解決案 3 と同様に, deep merge を行う関数を誰が用意するのかという問題が生まれています. また, 書き換える前の設定では ESLint が行っていた deep merge の方法と, 書き換えた後の deep merge の方法が一致しているかわからない (同じ意味の設定である保証がない) という懸念もあります.

結局どうしたか

共有設定を提供する立場で, 上記の課題をどう解決するかを考えたのですが, とりあえずは解決案 3 のように共通部分を展開するユーティリティ関数を, 設定と一緒に提供することにしました. この方法は独自に deep merge を行う場合のような, 設定の意味が変わってしまうといった懸念もないですし, もし将来的にエコシステムが発展して別の方法が定められたとしても, 単にこのユーティリティが使われなくなるだけで, 設計上の負債も残らないはずです.

"use strict";

// config だけでなく, ユーティリティ関数 map も提供される
const { config, map } = require("@susisu/eslint-config");
const eslintConfigPrettier = require("eslint-config-prettier");
const globals = require("globals");

module.exports = [
  // ソースファイル (TypeScript) 用の設定
  ...map(
    {
      // *.ts ファイルに対して.
      files: ["**/*.ts"],
    },
    [
      // 共有設定を拡張し,
      config.tsTypeChecked,
      eslintConfigPrettier,
      // プロジェクトごとの設定を追加する.
      {
        languageOptions: {
          ecmaVersion: "latest",
          sourceType: "module",
          parserOptions: {
            project: "./tsconfig.json",
          },
          globals: globals.es2021,
        },
      },
    ],
  ),
  // その他のファイル用の設定...
];

まとめ

ESLint の新しい設定ファイルの形式である Flat Config において, 共有設定をどのように使うべきか, あるいは共有設定をどのように提供すべきかについて考えました. とりあえずは共通部分を展開するようなユーティリティ関数を提供することで, Flat Config の枠組みの中で上手く共有設定を扱うことができそうです.