TypeScriptにおける代入可能関係の推移性
2023/08/10小ネタです。
代入可能関係
例えば、true型はboolean型に代入できます。
const booleanVar: boolean = true;
このことを、本記事ではtrue -> boolean
と表記します1。「true型はboolean型に代入可能(Assignable)」と読みます2。
以下は、TypeScriptにおける様々な型の代入可能関係をグラフに表したものです。クリック / タップすると拡大します。
図に登場する型の定義
enum NumericEnum {
A = 1,
B = 2,
}
enum StringEnum {
A = "A",
B = "B",
}
const esSymbol = Symbol("unique");
const undefinedVariable: undefined = undefined;
const nullVariable: null = null;
const voidVariable: void = undefined;
const booleanVariable: boolean = true;
const trueVariable: true = true;
const falseVariable: false = false;
const stringVariable: string = "string";
const template_literalVariable: `template literal` = "template literal";
const string_literalVariable: "string literal" = "string literal";
const string_enumVariable: StringEnum.A = StringEnum.A;
const bigintVariable: bigint = 123n;
const bigint_literalVariable: 123n = 123n;
const es_symbolVariable: symbol = esSymbol;
const unique_es_symbolVariable: typeof esSymbol = esSymbol;
const number_literalVariable: 3 = 3;
const numberVariable: number = 4;
const numeric_enumVariable: NumericEnum.A = NumericEnum.A;
const arrayVariable: number[] = [1];
const tupleVariable: [number] = [1];
const functionVariable: () => void = () => {};
const non_primitive__object_Variable: object = {};
const ObjectVariable: Object = {};
const __Variable: {} = {};
const unknownVariable: unknown = {};
const neverVariable: never = (() => {
while (true) {}
})();
推移性
推移性とは、「A -> B
かつB -> C
ならば、A -> C
である」という性質です。
TypeScriptの代入可能関係は、ほとんどの部分で推移的です。
例えば、true型、boolean型、unknown型について考えてみます。以下の通り、「true -> boolean
かつboolean -> unknown
ならば、true -> unknown
」です。
const v1: true = true;
// true -> boolean
const v2: boolean = v1;
// かつ
// boolean -> unknown
const v3: unknown = v2;
// ならば
const v4: true = true;
// true -> unknown
const v5: unknown = v4;
これは直感的にも納得できると思います。
推移性が成り立たないケース
以上を踏まえて、代入可能関係のうち、推移的でない部分を表示したのが以下の図です。
一気に見づらくなっちゃいました。すみません。
バツの矢印は、「根本の型が先(バツのある方)の型に、他の型を経由して代入できるが、直接は代入できない」ことを表します。
図にはバツの付いている型が2つありますので、それぞれ説明します。
Numeric Enums
Numeric Enumsは、メンバが数値のenum型です。
図を見ると、numberリテラル型はnumber型を経由してNumeric Enums型に代入可能ですが、直接は代入できないようです。
試してみます。
enum NumericEnum {
A = 1,
B = 2,
}
// number型を経由して代入できるが
const v1: 123 = 123;
const v2: number = v1;
const v3: NumericEnum = v2;
// 直接は代入できない
const v4: 123 = 123;
const v5: NumericEnum = v4; // 型エラー
不自然なのは、number -> NumericEnum
である部分です。こうなっている理由はchecker.tsに書かれています。
Type number is assignable to any computed numeric enum type ...(中略)... These rules exist such that enums can be used for bit-flag purposes.
訳:number型は、任意の計算されたNumeric Enum型に代入できる ...(中略)... これらの規則は、Enum型をビットフラグの目的で使用できるように存在する。
出典: checker.ts、TypeScript、DeepLによる訳を筆者が改変した
「Enum型をビットフラグの目的で使用」とは、例えば以下のようなことです。
enum Foods {
Ramem = 1 << 0, // 1
Curry = 1 << 1, // 2
Sushi = 1 << 2, // 4
}
const favoriteFoods: Foods = Foods.Ramem | Foods.Sushi;
console.log(favoriteFoods); // 5
このような使い方をするためには、Foods型に1、2、4だけでなく0〜7を代入できる必要があります。よって一般的には、number型すべてをNumeric Enums型に代入可能であるわけですね。
const flags: number = 5;
const favoriteFoods: Foods = flags;
object(non-primitive)
Object型や{}型には、オブジェクトの他に一部のプリミティブ型が代入できます。
// オブジェクト型を代入できる
const v1: Object = { hoge: 123 };
const v2: {} = { hoge: 123 };
// 一部のプリミティブ型も代入できる
const v3: Object = true;
const v4: {} = true;
一瞬「なぜ?」と思ってしまいますが、オブジェクトラッパーのことを考えると「まぁ一理あるか……」となります。
const v: boolean = true;
// Booleanオブジェクトラッパーのことを考えると、toStringプロパティがあるオブジェクトにも見える
console.log(v.toString()); // "true"
// なので以下ができる
const v2: { toString: () => string } = v;
const v3: {} = v;
一方、object型はnon-primitive型とも呼ばれ、プリミティブ型を代入できません。
// オブジェクト型を代入できる
const v1: object = { hoge: 123 };
// プリミティブ型は代入できない
const v2: object = true; // 型エラー
ところが図を見ると、他の型を経由することでプリミティブ型を代入できるとのことです。
やってみます。
// 他の型を経由することでobject型にプリミティブ型を代入できる
const v1: true = true;
const v2: Object = v1;
const v3: object = v2;
Object.create(v3); // 実行時エラー
// ^? (method) ObjectConstructor.create(o: object | null): any (+1 overload)
上記のコードは型検査をパスしますが、実行時にエラーになります。Object.create
の引数はobject | null
型を期待しているのに、実際にはtrue
が渡されるからです。
コードの中で良くなさそうなのは、Object -> object
である部分です。Object型の変数はプリミティブ値になりうるのに、non-primitiveであるobject型の変数に代入できてしまっています。
なぜこうなっているのか?という理由を調べたのですが、公式の明確な記述は見つけられませんでした。
Object -> object
は、typescript@2.2.0でobject型が導入された当初からの挙動で、当時のPRや提案のIssueにヒントがないかと読んでみたのですが、結局わかりませんでした。
したがって完全な予想になりますが、おそらくコードの互換性のための挙動なのかなと思いました。object型の導入以前に書かれていたコードと、それ以降の新しいコードを共存させるために必要だった……とか?うーん。
もし理由をご存知であれば教えていただけると嬉しいです。
まとめ
代入可能関係の一部が推移的でないことを説明し、そのような2つのケースについて見ました。
正直、ここに挙げたようなコードは業務で1度も書いたことがないので、あまり気にしなくても良いという説があります。小ネタということでご容赦ください。
代入可能関係の図を生成したコードは以下にあります。
https://github.com/sititou70/ts-extends-hierarchy
暑い中ありがとうございました。
- これは本記事独自の表記で、一般的なものではないです。↩
- Assignableの定訳は「割り当て可能」かもしれません。少なくともO'Reillyの「初めてのTypeScript」ではそのように訳していました。代入可能という単語がタイトルやURLにも既に含まれてしまっているため、記事全体の修正ではなく、脚注による補足に留めさせてください。すみません。↩