never型とプロパティアクセス
TypeScriptにはneverという型があります。先日も記事にしましたが、簡単に言うとこれは「値を持たない型」です。
never型は、あらゆる型に対してその部分型として振る舞います。例えば、never
が number
の部分型であることは次のコードでわかります:
type T<U extends number> = {};
type A = T<string>; // stringはnumberの部分型ではないのでエラー
type B = T<never>; // neverはnumberの部分型なのでコンパイルが通る
さて、部分型関係と、「その型の式に対してできる操作の集合」の関係について考えましょう。あまり面白い例を思いつかなかったのでアレですが、次の型を考えます:
type UserInfo = { name: string };
type DetailedInfo = { name: string, age: number };
// DetailedInfo は UserInfo の部分型となっている
UserInfo
型の値 x
に対しては x.name
というプロパティアクセスができるのに対し、DetailedInfo
型の値 y
に対しては y.name
に加えて y.age
というプロパティアクセスもできます。つまり、「部分型である DetailedInfo
型は、UserInfo
型と比べると、その型を持つ式に対してできる操作が多い」のです。
ということは、あらゆる型の部分型として振る舞うneverという型は、その型を持つ式に対してあらゆる操作を許容すべき、となります。値がないのだから、その型を持つ式に対してどんな操作を行なっていようとそのコードは到達不能で、問題にはなり得ません。
例えば、次のコードはコンパイルが通ります:
declare const f: (x: number) => void;
declare const g: (y: string) => void;
function h(x: never) {
f(x); // x に対して f という操作を実行できる
g(x); // x に対して g という操作を実行できる
}
「プロパティアクセス」という操作についても同じことが言えるべきです。例えば、never
は UserInfo
の部分型として振る舞うので、次のコードはコンパイルが通ります:
type UserInfo = { name: string };
function f(x: never): string {
const y: UserInfo = x;
return y.name;
}
ならば、次のコードもコンパイルが通るべきではないでしょうか:
type UserInfo = { name: string };
function f(x: never): string {
return x.name;
}
ところが、実際にやってみると、次のエラーが出ます:
Property ‘name’ does not exist on type ‘never’.
これは推測ですが、TypeScriptコンパイラは型システムの気持ちよりもlinterの気持ちを優先させて、「never型にわざわざプロパティアクセスするコードはプログラマの見落としによる産物の可能性が高い」と判断したのかもしれません。強調しておきたいのは、「never型の式にプロパティアクセスができない」という動作は型システムの一貫性から外れた、アドホックな(場当たり的な)動作であるということです。
網羅的なswitch
話を変えて、discriminated unionを使った次のコードを考えましょう:
type T = { tag: "A", payload: string }
| { tag: "B", payload: number };
function f(x: T): string {
switch (x.tag) {
case "A": return x.payload;
case "B": return x.payload.toString(16);
}
}
このswitch文は網羅的です。つまり、ありうるすべての場合を尽くしています。
しかし、TypeScriptの型システムはいくらでも迂回ができます。実行時には x.tag
が "A"
でも "B"
でもない値を取るかもしれません。そこで、そういう場合にエラーを投げることにしましょう:
type T = { tag: "A", payload: string }
| { tag: "B", payload: number };
function f(x: T): string {
switch (x.tag) {
case "A": return x.payload;
case "B": return x.payload.toString(16);
default: throw new Error(`invalid tag: ${x.tag}`)
}
}
ところが、書き換えた後のコードはコンパイルエラーが出ました!エラーメッセージは、そう、never
型に関するものです:
Property ‘tag’ does not exist on type ‘never’.
はぁ〜〜〜?????
型システム上は x.tag
は "A"
か "B"
のどちらかであり、default節には到達しないはずなので x
がneverになっているんですね。never型へのプロパティアクセスなのでエラーになる。
TypeScript的には「switchのdefaultが無駄だよ」と教えてくれているつもりなのかもしれませんが、熟練のプログラマには余計なお世話です。「プログラマは何もかも分かった上で意図的にこういうコードを書いているんだ」と伝えるには、as
を使うという手があります:
type T = { tag: "A", payload: string }
| { tag: "B", payload: number };
function f(x: T): string {
switch (x.tag) {
case "A": return x.payload;
case "B": return x.payload.toString(16);
default: throw new Error(`invalid tag: ${(x as any).tag}`)
}
}
nullableへのoptional chaining
今度は、私が最近遭遇した事例を紹介します。元々は、なんらかのnullableなデータに対してoptional chainingをやっていました。optional chainingじゃなくても、== null
でテストして中身へアクセスするコードでも良いです。
declare function getSomeData(): { someData: string } | null;
const x = getSomeData();
console.log(x?.someData);
if (x == null) {
console.log("x is null");
} else {
console.log(x.someData);
}
これはいいですね。当然コンパイルも通ります。
ある日、改修の都合で一時的に getSomeData
の呼び出しを削除する必要が出てきました。最終的なコードがどうなるかはわからないので、getSomeData
の呼び出しを復活させることになっても良いように、変更は最小限に留めたいです。
一番簡単なのは、getSomeData
の呼び出しを null
に置き換えることでしょう。型システム上も問題ないはずです。null
には { someData: string } | null
という型がつきますからね。
const x: { someData: string } | null = null;
console.log(x?.someData);
if (x == null) {
console.log("x is null");
} else {
console.log(x.someData);
}
ところが!このコードはコンパイルが通らないのです!!!
Property ‘someData’ does not exist on type ‘never’.
どういう理由でTypeScriptがエラーを出すのか調べるのは難しいことではありません。要点は以下の3つです:
x
の実際の型はnull
になる(!)if
文やoptional chainingの、x != null
の場合では、x
の型はnever
になる(到達不能なので)never
型を持つ式にはプロパティアクセスができない
「x
の実際の型が null
になる」というのはやや意外ですが、真っ当な型システムならこれでもいいんです。変数により詳細な型がつくのは良いことですからね。型検査器の実装者が部分型という概念に真面目に向き合っていれば、問題は起きないはずです。
真っ当な型システムなら、x != null
の場合に x
の型が never
になってもいいんです。never
は { someData: string }
の部分型ですからね。
ところが、TypeScriptは真っ当な型システムを備えていなかったので、問題が起こりました。TypeScriptは「never型を持つ式へのプロパティアクセスを禁止する」という場当たり的な仕様を採用していたため、上記のコードがエラーになったのです。
回避策は、as
を使うことです。as
は一般には型システムを迂回できる危険な機能ですが、TypeScriptコンパイラに「プログラマは分かった上でこういうコードを書いている」と伝達する手段は多くはないので、正当な使用だと言えるでしょう。
const x: { someData: string } | null = null as ({ someData: string } | null);
console.log(x?.someData);
if (x == null) {
console.log("x is null");
} else {
console.log(x.someData);
}
まあ、TypeScriptの言い分も理解できなくはありません。今回は一時的に x
の定義を null
としましたが、x
に関するコードは最終的には「getSomeData
の呼び出しを復活させる」か「x
に関するコードを全部削除する」という形で解決されるべきものです。そういう意味では、コードが問題を抱えていることは確かです。そんなことはプログラマは百も承知で、TypeScriptに指摘されるまでもないのですが。
雑感
「never型へのプロパティアクセス」は一律禁止ではなくて、せめてwarningにならないのかと思います。
どこかの言語では「コード中に未使用のimportが存在してはいけない(コンパイルを通さない)」らしいですが、TypeScriptのこの動作からはそれと同じような精神を感じます。コンパイラじゃなくてlinterの領域にはみ出しているというか。まあTypeScriptはそれ自体が壮大なlinterとも言えますが。