null or undefined #kyotoasterisk とその補足など

Kyoto.なんか #6 で発表しました.

speakerdeck.com

以下はその補足情報など.

仕様書中の出現頻度

null と undefined がそれぞれの仕様でどの程度使われているのかは, 仕様書中の出現頻度を見るだけでもある程度わかりりそうです. ということで ECMAScript 2024WHATWG の標準 (2024-09-07 時点) のうちいくつかの仕様書の中での出現頻度を見てみましょう.

Spec #null #undefined
ES2024 300 939
DOM 368 120
Fetch 276 10
HTML 1778 275
URL 98 9

見ての通り, 顕著に登場頻度に差があることがわかりますね.

なお上記の null の出現数には WebIDL の nullable (T?) を含めていないため, Web 標準における実際の null の登場頻度はもっと多くなるはずです.

仕様書の null 全部読む

上記の通り ES2024 の仕様には null が 300 回しか登場しません. この程度なら null の登場箇所は全部読めるなと思ったので読みました.

いくつか手元のメモから面白かった箇所を抜粋します:

  • null がオブジェクトの prototype となるのは, プログラマがそうした場合を除くとたぶん以下のいずれか
  • Date.prototype.toJSON は時刻が finite でないとき null を返す
  • String.prototype.matchAll が返すイテレータは null を返す的なことが書いてあるが, たぶん普通はそんなことはない
  • Array.prototype.join は配列中の null や undefined を無視する
  • Object.assign は引数中の null や undefined を無視する
  • Map, Set, WeakMap, WeakSet のコンストラクタは引数に null や undefined が渡されると無視する

ECMAScript の言語仕様での例外的なケースについて

スライド中でも紹介した通り, ECMAScript の言語仕様中で null が使われるのは (仕様内部での利用を除けば) 以下の 3 つです.

このうち JSON で null が使われる理由は明白で, JSON の仕様でそう定義されているためです.

正規表現についてはおそらく歴史的な事情で, 今となっては特に深い意味はないものと思われます. (この理由に関する情報を昔見かけたことがある気もしますが, 真偽が定かでないのでここで広めるのはやめておきます.)

プロトタイプについては, ES5 より前には仕様内部で用いられていたものが, ES5 以降に Object.getPrototypeOf などが追加されたことで正式に表に出てくるようになったものと考えています.

Web 標準仕様で例外的なケースについて

例えば CustomElementRegistry#get が custom element が見つからない時に undefined を返すようです.

オブジェクトや文字列に対しては null を使うのでは?

文字列は JavaScript ではプリミティブ値の一種ですが, 他の言語ではオブジェクト側に分類されることもあります. 実際, ECMAScript の言語仕様における例外ケースや Web 標準の慣例は, こういった他の言語の仕様や慣習に由来するものでほぼ間違いないでしょう.

一方, その他のプリミティブ値に対してであったり, プリミティブ値とオブジェクトが混在する場合に null と undefined のどちらを使うのかは, これだけでは説明できないはずです. ECMAScript の言語仕様でもこういった場合に null を使っても良いはずですが, 原則 undefined を使うように統一されています.

また TypeScript を使う場合に Map<string, string> と型を制限したからといって get() したときに null が返るかというとそんなことはないので, やはり出自も考えるほうが混乱しないでしょう.

ESLint の Flat Config を書く時に読んでほしい記事 (2024-08版)

ESLint v9 から Flat Config がデフォルトの設定ファイルの形式となり, 徐々に対応しているプラグインも増えて移行が進みつつありますが, 実際に移行したプロジェクトを見ているとしばしば勘違いなどから誤った設定をしている事例を目にします. ということで, Flat Config を書くにあたっていくつか知っておいて欲しいことや, よく見かけるミスをまとめてみました.

この記事では網羅的な説明はしませんので, ESLint や typescript-eslint の公式ドキュメントを前提として, 副読本的に参照してください.

Flat Config のしくみ

Flat Config は config object と呼ばれるオブジェクトの配列で記述されます.

/* eslint.config.js */

export default [
  // ↓ これが config object
  {
    files: ["**/*.js"],
    rules: {
      "no-console": "error",
      "no-empty-function": "warn",
    },
  },
  // ↓ こっちも
  {
    files: ["**/*.test.js"],
    rules: {
      "no-empty-function": "off",
    },
  },
  // ...
];

各 config object は二つの部分からできています.

  • 設定の対象を記述する部分 (files, ignores)
    • 記述がなければ任意のファイルが対象となります
  • 設定の内容を記述する部分 (plugins, languageOptions, rules など)

あるファイルを lint する際にどのような設定が適用されるかは, 以下のようにして決定されます.

  1. そのファイルを対象に含めている config object を全て取り出す
  2. それらの config object の内容を先頭から順番にマージする
    • 同じ設定項目については後勝ちです. オブジェクトは良い感じにマージされます

typescript-eslint の提供するユーティリティを使うのがおすすめ

ESLint や typescript-eslint, 各種プラグインなどが提供する recommended のような共有設定を利用する場合, これまでの設定ファイルの形式では extends による設定の継承が行われてきました.

module.exports = {
  overrides: [
    {
      files: ["*.ts"],
      extends: [
        // 継承元 (1)
        "eslint:recommended",
        // 継承元 (2)
        "plugin:@typescript-eslint/recommended",
      ],
      rules: {
        "no-console": "error",
      },
    },
    // ...
  ],
};

ところが Flat Config では以前と異なり, config object が extends を使った継承をサポートしていません*1. かわりに以下のように対象 (filesignores) を同じくする config object を順番に書き, 最終的にそれらがマージされた設定が使われることで, 継承と同等の効果を実現できます.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default [
  // 継承元 (1)
  {
    files: ["**/*.ts"],
    ...eslint.configs.recommended,
  },
  // 継承元 (2)
  ...tseslint.configs.recommended.map(config => ({
    files: ["**/*.ts"],
    ...config,
  })),
  {
    files: ["**/*.ts"],
    rules: {
      "no-console": "error",
    },
  },
  // ...
];

まあこれはあまりに煩わしいので, typescript-eslint が tseslint.config() というユーティリティを提供してくれています. このユーティリティには config object の列を渡すことができますが, これらの config object には追加で extends を渡すことができます. 結局は上記と同じ形に展開されますが, 冗長な記述が減るため非常に便利です.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    files: ["**/*.ts"],
    extends: [
      // 継承元 (1)
      eslint.configs.recommended,
      // 継承元 (2)
      ...tseslint.configs.recommended,
    ],
    rules: {
      "no-console": "off",
    },
  },
  // ...
);

詳しいことは公式のドキュメント実装を参照してください.

ファイルの種類ごとに設定を記述するのがおすすめ

typescript-eslint のドキュメントを読むと, 最初に以下のような記述が案内されています.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
);

これは TypeScript のファイルのみを lint するのであれば良いのですが, 同時に JavaScript で書かれた設定ファイルも lint するなど, 複数の種類のファイルを lint の対象にしたい場合は, この記述はあまりおすすめしません.

というのも, 実は上記の設定では全てのファイルが TypeScript として構文解析され, lint されることになります*2. これには一長一短あり, JavaScript ファイルに対しても型を使ったルールが使えるようになる一方で, JavaScript としては不正な構文 (型注釈など) が含まれていても lint を通過してしまいます.

2024-08-17 追記: typescript-eslint の一部のルールを有効化するにあたっては衝突する ESLint 組み込みのルールを無効化する必要があるのですが, typescript-eslint v8.1.0 時点で上記の設定を行った場合, このルールの無効化が .ts など TypeScript ファイルに対してのみ行われるようになっています. これでは JavaScript ファイルに対しては不正な設定となってしまうことから, 元より JavaScript ファイルを同時に lint することを想定していない (か意図せずそうなってしまっている) のだと思われます.

個人的には, まずは以下のようにファイルの種類ごとに設定を行うところから始めるのが, やや設定は複雑になるものの無難であろうと考えています.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  // .js に対する設定
  {
    files: ["**/*.js"],
    extends: [eslint.configs.recommended],
  },
  // .ts に対する設定
  {
    files: ["**/*.ts"],
    extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
  },
);

JavaScript ファイルに対しても型を使ったルールを使いたいといった場合は, 上記の事情を理解した上で設定をしましょう.

自分で config object をマージする際の注意

tseslint.config() を使わずに共有設定を使用する際のよくあるミスとして, 以下のような記述がされることがあります.

import eslint from "@eslint/js";

export default [
  {
    files: ["**/*.js"],
    ...eslint.configs.recommended,
    rules: {
      "no-console": "error",
    },
  },
];

ところがこれでは eslint.configs.recommended に含まれる rules を, 直後に記述された rules で全て上書きしてしまう (この場合, no-console のみが有効となり, eslint.configs.recommended に含まれていたルールは全て無効になる) ため, 正しい記述ではありません.

正しくは以下のように別々の config object を記述して, ESLint に rules をマージしてもらうようにします.

import eslint from "@eslint/js";

export default [
  {
    files: ["**/*.js"],
    ...eslint.configs.recommended,
  },
  {
    files: ["**/*.js"],
    rules: {
      "no-console": "error",
    },
  },
];

あるいは素直に tseslint.config()extends を使いましょう.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    files: ["**/*.js"],
    extends: [eslint.configs.recommended],
    rules: {
      "no-console": "error",
    },
  },
);

eslint-config-prettier は最後に書いておくのがおすすめ

ESLint と Prettier を併用する場合 eslint-config-prettier を使うことがありますが, これはファイルの種類ごとの extends の中ではなく, Flat Config の配列の末尾に置いておくのが最もシンプルです.

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";

export default tseslint.config(
  {
    files: ["**/*.js"],
    extends: [eslint.configs.recommended],
  },
  {
    files: ["**/*.ts"],
    extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
  },
  prettier,
);

とはいえ現在では Prettier と衝突するようなフォーマットに関するルールは ESLint や typescript-eslint からは除かれていっているため, eslint-config-prettier の必要性は少なくなってきているとは思います.

その他のよくあるミス

  • 拡張子が .js である全てのファイルを対象にする場合は files: ["**/*.js"] のように記述します
    • 旧設定ファイルで同様の記述をする際 (overrides 内) は files: ["*.js"] と書きましたが, Flat Config ではこれはルートディレクトリにある拡張子が .js のファイルという意味になり, 対象となるファイルが限られてしまいます
  • CommonJS ファイルに対して指定すべき languageOptions.sourceType"commonjs" です
    • 旧設定ファイルでは "script" でしたが, Flat Config ではこれは特にモジュールを規定しないスクリプトという意味になり, strict のようなルールが意図しない動作をすることがあります
  • @eslint/jsglobals といったパッケージを使用する場合は, 明示的にインストールして devDependencies に含めるようにしましょう
    • これをしないと, 偶然そこにあっただけの意図しないバージョンのものを使用してしまう可能性があります

*1:継承のような入れ子構造を持たず, 一次元的な配列で記述されることから Flat Config と呼ばれています

*2:そんなの知るかよって感じですよね. 私もそう思います. こういった共有設定を使うのは簡単ではありますが, 何をしてくれるか (あるいは何をしてくれないか) についての説明が書かれることは typescript-eslint に限らず少なくて, 正しく理解するためにはソースコードを読みに行く必要が生じることがほとんどです. このわかりづらさは課題です.