型なんとか

いろいろあって毎回説明するの大変なのでまとめておきます.

型変換 (type conversion)

基本的には明示的 / 暗黙的といった手段によらず, ランタイムの値を別の型に変換することを指していると思います. ランタイムの値が変わるのだから, 結果として静的な方の型も変わることにはなります.

JavaScript / TypeScript でいうと, typeof x === "number" な x に対して String(x) と明示的に変換することもできるし, "foo" + x のように (ここでは数値を文字列の文脈で使うことで) 暗黙的に変換されることもあります.

型キャスト (type casting)

基本的には明示的な手段を使って, ある値を特定の型の値として使えるようにする・そう表明すること全般を指しているかと思います. 多くの場合ランタイムの値の変換を伴いますが, アップキャスト / ダウンキャストに代表されるようにランタイムで値の変換は必ずしも行われないケースもあり, なんにせよその型の値として使えるようになるという部分が重要そうです. もしかしたら暗黙的な型変換のこともキャストと呼ぶこともあるかもしれないですが, 私はあんまり聞いたことはないかも.

JavaScript / TypeScript でいうと, String(x) はキャストと呼ぶけれど, "foo" + x での暗黙的な型変換をキャストと呼ぶ人はあんまりいなさそう ("" + x 全体を String(x) と同等のキャストと呼ぶことはあります).

また TypeScript の t as T も (ランタイムの値の変換のない) 型キャストの一種と呼べそうですし, 実際ダウンキャストするケースではこれを使いますが, ランタイムで値が変換されるという誤解を招きやすいため, 一般にこう呼ぶのはおすすめはしません.

型強制 (type coercion)

これは知っている限り二種類ありそう.

一つは型変換と同じものを指すケースで, 特に MDN の type coercion のページで紹介されているように暗黙的な型変換のみを指すことがあります.

対してもう一つ, Haskell などではランタイムの値を変換することなく静的な型だけを別のものに変える操作をこう呼ぶことがあります (例: Data.Coerce). TypeScript の t as T も役割はほぼ同じなので, その意味では type coercion とも呼べそうですが, 前述の暗黙の型変換と混同される恐れがある (特にググるとそっちが最初に出てくる) 点には注意してください.

型アサーション (type assertion)

一般には, ある値がその型であると表明・主張し, 静的または動的に主張が妥当かどうかを検査するという感じにまとめられそうです. ランタイムの変換は行われなくて, 検査に失敗したらエラーが発生するという挙動が想定されそう.

TypeScript の t as T の正式名称は type assertion となっています (出典). 動的な検査は全く行われませんが, 静的検査は部分的に行われていて, 以下のように全く無関係の型と主張するような明らかに間違っているケースについてはコンパイルエラーになります. 一方でダウンキャストについては特にエラーにならず, この点では type coercion の二つ目と同等の挙動です.

declare const x: number;
x as string; // コンパイルエラー

declare const y: { foo: number };
y as { bar: string }; // コンパイルエラー
y as { foo: number; bar: string }; // エラーにならない

最近 Bun が AoT で type assertion を型変換にコンパイルしよう的なことを言っていて (出典), なんというか無頓着だなあと思いました.

型表明 (type ascription)

文ではなく式の中に型注釈 (type annotation) を直接書けるようなもので, ある値が特定の型であることを表明し, 静的に検査を行います. 型推論の結果が一意に定まらない場合などに明示的に型を宣言するようなケースでよく使われ, 普通は式の型は宣言したものになります.

性質的に型注釈とほぼ同じものであることから, 構文も同じ記号が使われていることが多いように思います. 例えば Scala や Haskell では t: T や t :: T です.

TypeScript の t satisfies T は機能的に近いものに見えます (特に t as T と比べて静的検査が確実に行われるという点で) が, 必ずしも式の型が宣言した型 T にはならない (原則としては元々の t の型のまま. ただし t の型は宣言されている T を元に推論されることはある) という違いがあります.

Flow が type casting と呼んでいるものは機能的には type ascription 相当のものです. 昔は型注釈と同じ記号で (t: T) という構文を使っていたのですが, ある時 TypeScript の type assertion に寄せて t as string という構文になりました. なんというか無頓着だなあと思いました.

まとめ

それぞれ一般的に理解されている共通認識はありつつも, 細かいところは文脈や使っている言語による部分もあるので, まあいい感じにやりましょう.

Oxlint の共有設定みんなどうしてる?

前提: ESLint の設定方法と共有設定

ESLint での共有設定の主な役割は, プラグインの設定とルールの設定の二つです.

ESLint 本体には JavaScript に関する基本的なルールしか組み込まれていません. そのため TypeScript など異なる言語に対するルールや, Node.js, React などの特定の環境やライブラリ固有のルール, その他発展的なルールを使うためには, 追加でプラグインをインストールして設定を記述する必要があります. この設定はそれなりに (プロジェクトごとに毎回記述するにしては) 複雑になることもあり (例: TypeScript で import plugin を使うときの設定), プラグインが提供する共有設定を使って設定するのが普通です.

また組み込みのルールも含めて, 実際にルールを適用するには個別にルールを有効化・オプションの設定を行う必要があります. これについても, ESLint 本体やプラグインが提供する共有設定 (例: ESLint の recommended) を使ってまとめて有効化するのが一般的かと思います.

さらに一段メタな共有設定として, 普段使うプラグインの組み合わせや, 上記のような recommended を超えてカスタマイズしたルール設定をまとめて提供するというのも行われてきました (例: eslint-config-teppeis, @antfu/eslint-config など).

Oxlint の設定方法と共有設定

Oxlint においても ESLint と同じくプラグインやルールの設定を行いますが, 状況は少し異なります.

まずはプラグインについて. Oxlint には組み込みのプラグインとして, ESLint 本体やメジャーな ESLint プラグインのルールが移植されたものが含まれています. そのためプラグインを使いたいときの設定は, ほとんどの場合は下記のように設定ファイルの plugins に名前を追加するだけで完了します. デフォルトでは eslint, typescript, unicorn, oxc が有効になっているので, シンプルなプロジェクトでは設定の必要すらないかもしれません.

import { defineConfig } from "oxlint";
export default defineConfig({
  // NOTE: eslint プラグインは書かなくても常に有効
  plugins: ["typescript", "unicorn", "oxc", "react"],
});

続いてルールについて. Oxlint の組み込みルールにはそれぞれカテゴリ (correctness, suspicious, pedantic など) が付与されており, 下記のようにカテゴリごとにまとめて有効化・警告レベルを設定できるようになっています. デフォルトでは { correctness: "warn" } が設定されており, だいたい ESLint の recommended 相当のルールがデフォルトで有効化されるので, こちらも設定不要な場合もありそうです.

import { defineConfig } from "oxlint";
export default defineConfig({
  categories: {
    correctness: "error",
    suspicious: "warn",
  },
});

このように Oxlint は共有設定がなくても簡単に初期設定が行えるようになっています. 実際, Oxlint 自体は recommended のような共有設定を提供していません.

一方で, 共有設定を作成する必要が全くなくなったかというと, 特により厳格な・好みに合ったルール設定をしたい場合についてはそういうわけでもなさそうです. 上記のカテゴリによるルールの一括有効化は万能ではなく, 例えば pedantic カテゴリには eqeqeq, max-lines などのより厳格な記述をするためのルールが含まれますが, 一方でどこまで・どういった種類の厳格さを求めるのかは好みによります (私は eqeqeq は欲しいですが max-lines (デフォルトは最大 300 行) は厳しすぎと思います). こういったカテゴリについては { pedantic: "error" } のように一括で有効化するわけにもいかないので, 個別のルールを好みに応じて有効化・オプションを設定する必要があり, 複数のプロジェクトに同じルールを適用したければ共有設定として配布するのが便利なはずです.

Oxlint の共有設定をどう作るか

共有設定は, 素朴には以下のように普通に設定を書けば定義できて,

// shared.ts
import { defineConfig } from "oxlint";
export const shared = defineConfig({
  rules: {
    eqeqeq: "error",
    "no-implied-eval": "off",
    "typescript/no-implied-eval": "error",
  },
});

利用側では共有設定を extends することで使えます. 良かったですね.

// oxlint.config.ts
import { defineConfig } from "oxlint";
import { shared } from "./shared.ts";
export default defineConfig({
  extends: [shared],
  options: {
    typeAware: false,
  },
  categories: {
    correctness: "warn",
    suspicious: "warn",
  },
});

ここからがこの記事の本題.

上記の設定の重箱の隅をつつくと,

  • 利用側の categories で明に指定されている最大の警告レベルが warn なのに, 共有設定のルールの警告レベルだけ error で高くなっちゃうけど良いの?
  • 仮に利用側で明に categories: { pedantic: "off" } が指定されたときも eqeqeq は有効のまま (rules の方が優先度が高い) だけど, それで良いの?
  • typescript/no-implied-eval は options.typeAware が有効でないと動かないルールで, 実際利用側で options.typeAware が無効化されて動いてないのに, フォールバック先として機能する no-implied-eval を off にしちゃっていて良いの?

みたいなところが気になります. 特にカテゴリの設定と個別ルールの設定が衝突するのは ESLint の共有設定では存在しなかった問題です.

利用側で共有設定の中身を理解して上手くやってくれと切り捨てる, あるいはカテゴリや options.typeAware などの設定もすべて共有設定で行うことにして利用側の自由度を減らすといったこともできそうではあるのですが, 利用側の自由度を残しつつも共有設定の側でもっと上手くやる方法がないかを最近は考えていて, susisu/oxlint-config で実験していました (未完成です). 具体的には以下のように, 利用側で各種ユーザー設定が行われることを前提に, 共有設定はそれを引数にして有効にする rules を出し分けられると良いんじゃないか. (実は CLI のオプションでも設定ができるけど, それは引数には入って来ないという欠点はありつつ.)

// shared.ts
import type { OxlintConfig } from "oxlint";
import { defineConfig } from "oxlint";

export function shared(userConfig: OxlintConfig = {}): OxlintConfig {
  return defineConfig({
    ...userConfig,
    extends: [いい感じにやる(userConfig), ...(userConfig.extends ?? [])],
  });
}
// oxlint.config.ts
import { shared } from "./shared.ts";
export default shared({
  options: {
    typeAware: false,
  },
  categories: {
    correctness: "warn",
    suspicious: "warn",
  },
});

ここで いい感じにやる の中身は,

  • ルールごとの警告レベルは, ユーザー設定でカテゴリ単位で明に指定されているものがあればそれに従い, 明に指定されていなければ空気を読んで他のカテゴリの警告レベルを超えないようにする
  • options.typeAware が有効でないと動かないルールと, それによって上書きされるルールの設定は, ユーザー設定で options.typeAware が有効なときだけ書き出す
  • 同様に, 特定のプラグインのルールと, それによって上書きされる (他のプラグインの) ルールの設定は, ユーザー設定でそのプラグインが有効なときだけ書き出す

みたいな感じにすると大体いい感じになるんじゃないかと思っています. ならないかもしれないですし, 複雑さに見合うメリットがあるのかも謎です.

皆さんはどうしていますか?