🎢

TypeScript 4.8で入る型の絞り込みの改善とは

2022/06/16に公開約6,600字

皆さんこんにちは。今回はTypeScriptの更新先取りシリーズです。TypeScriptの次のバージョンでは、以下のPRの更新が入ると思われます。もちろんPRの著者はAndersさんです。このPRではTypeScriptの根幹を成す機能の一つである「型の絞り込み」が改善されます。特に、unknown型と{}型の取り扱いが修正されている点が注目に値します。

https://github.com/microsoft/TypeScript/pull/49119

型引数に対する推論が抱えていた既存の問題

{}型は、「nullundefined以外の任意の値」という意味を持つ型です。この型は形としては空のオブジェクト型ですが、JavaScriptではnullundefined以外のプリミティブ(文字列や数値など)に対してもプロパティアクセスをしてもエラーにならないという仕様を考慮して、{}型には文字列や数値などのプリミティブも含まれています。

従来型引数に対する推論が抱えていた問題とは、任意の型引数が{}型に代入可能であることです。これにより、nullundefined{}型に入ってしまう場合がありました。

function someFunc<T>(x: T) {
    // エラーにならない!
    const some: {} = x;
}

someFunc(null);

TypeScript 4.8ではこの挙動が修正され、上記のコードはエラーになります。エラーを修正するには、次のようにTに制約をつけてnullundefinedを防ぐ必要があります。

function someFunc<T extends {}>(x: T) {
    // OK
    const some: {} = x;
}
// こちらがエラーになるので安全!
someFunc(null);

この辺りの背景としては、任意の値を表すunknown型が{}型に比べて新参であるという事情があります。そのため、歴史的経緯からextendsを持たない型引数は暗黙のうちにextends {}とみなされていたことになります。今回、それが修正されてあるべき姿になりました。ちなみに、TypeScript 4.7以前でもT extends unknownと明示的な制約をつければTnullundefinedの可能性があると認識されます。

unknown{}に絞り込めるようになった

unknown型はnullundefinedも含めて何でもあり得る値であり、まともにunknown型の値を使うには型の絞り込みを使う必要があります。例えば、typeof x === "string"とすればxunknown型からstring型に絞り込まれます。

従来、unknownからnullundefinedの可能性だけを除外することはできませんでした。

function someFunc(x: unknown) {
  if (x !== null && x !== undefined) {
    // TypeScript 4.7ではxがunknownのままのためエラー
    const y: {} = x;
  }
}

しかし、今回の修正により、この場合x{}型に絞り込まれるようになります。

function someFunc(x: unknown) {
  if (x !== null && x !== undefined) {
    // TypeScript 4.8ではエラーにならない!
    const y: {} = x;
  }
}

ちなみに、x !== nullだけだったりしてもうまく絞り込まれます。

function someFunc(x: unknown) {
  if (x !== null) {
    // TypeScript 4.8では {} | undefined 型になる
    x;
  }
}

以上の挙動は、型の絞り込みにおいてunknown{} | null | undefinedのように扱われるようになったと解釈できます[1]

また、!== null!== undefined以外の手段でもunknownを絞り込める可能性があります。例えば、if (x)のように真偽値チェックをする場合です。

function someFunc(x: unknown) {
  if (x) {
    // TypeScript 4.7ではunknown型のまま、
    // TypeScritp 4.8では{}型
    const y: {} = x;
  }
}

型引数に対する絞り込みの改善

型引数に対しても、{}にまつわる絞り込みの改善が行われています。

function someFunc<T extends unknown>(x: T) {
  if (x !== null && x !== undefined) {
    // TypeScript 4.7ではxはT型のまま
    // TypeScript 4.8ではxはT & {}型なのでOK
    const y: {} = x;
  }
}

NonNullableの定義の改善

以上のような一連の変更により可能になったことがあります。それは組み込みのNonNullable型の定義の改善です。これが一連の変更によって成し遂げたかったことなのだと推測されます。NonNullable<T>という型は、その名前が示すようにTからnull | undefinedの可能性を除いた型です。

従来のNonNullable<T>は次のような定義でした。

type NonNullable<T> = T extends null | undefined ? never : T;

これはconditional typesとunion distributionを駆使した型定義で、例えば{ x: number } | nullのような型に対してはうまく動きます。

type ObjOrUndefined = { x: number } | undefined;

type Obj = NonNullable<ObjOrUndefined>; // { x: number }

しかし、この定義には2つの問題がありました。一つは、unknown型がユニオン型ではないためうまく動かないことです。

type A = NonNullable<unknown>; // TypeScript 4.7ではunknownのまま

もう一つは、型引数に対してNonNullableを使っても、型引数の中身がわからないためconditioanl typeが未解決のままになることです。

function someFunc<T>(x: T) {
  type A = NonNullable<T>; // TypeScript 4.7では T extends null | undefined ? never : T のまま
}

Conditional typeが未解決のままの場合、代入可能性などの判定が難しいため多くの操作が型エラーとなってしまいます。

function someFunc<T>(x: T) {
  type A = NonNullable<T>; // TypeScript 4.7では T extends null | undefined ? never : T のまま
  if (x !== null && x !== undefined) {
      // TypeScript 4.7では型エラーになってしまう
      const obj: A = x;
  }
}

TypeScript 4.8では、NonNullableの定義が大胆に変更されます。新しい定義はこうです。

type NonNullable<T> = T & {};

これにより、上記の2つの問題が解決されます。

type A = NonNullable<unknown>; // TypeScript 4.8では{}になる

function someFunc<T>(x: T) {
  type A = NonNullable<T>; // TypeScript 4.8ではT & {}
  if (x !== null && x !== undefined) {
    // TypeScript 4.8では型エラーにならない
    const obj: A = x;
  }
}

例の後半のコードから分かるように、この新しい定義が自然に動くためには{}に関する絞り込みの改善が必要でした。

新しい定義は型推論との親和性が高く、例えば次のようなコードがasなどの補助無しにコンパイル可能になります。

function removeNullish<T>(value: T): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error("Huh?")
  }
  return value;
}

{} | null | undefinedが何でも受け入れるようになった

関連する話題として、{} | null | undefined型に対する取り扱いが改善されます。この型は実質あらゆる値を受け入れるためunknown型と同様ですが、従来はunknown型をこの型に代入することはできませんでした。これは、前者がユニオン型であり後者がユニオン型ではないからです。TypeScriptでは{} | null | undefinedに対する特別な処理が追加され、unknownをこの型に代入できるようになりました。

type PseudoUnknown = {} | null | undefined;

function someFunc(x: unknown) {
  // TypeScript 4.7ではエラー
  // TypeScript 4.8ではエラーにならない
  const y: PseudoUnknown = x;
}

このような変更によって、unknownの挙動が{} | null | undefinedにさらに近くなり、{}の「nullundefined以外全部」という側面がさらに強調されることになります。

その他の話題

ところで、次のような型はどのような挙動をとるでしょうか。

type A = string & {};

{}stringを完全に含んでいることを考えると、Astringになりそうです。しかし、実際はAstring & {}という型のままです(これはTypeScript 4.8でも変わりません)。

この変な挙動の理由は、プリミティブと{}のインターセクション型が次のようなハックに使われているからです(PRから引用)。

type Alignment = string & {} | "left" | "center" | "right";

この型は、意味的にはただのstringと同様に任意の文字列を受け入れますが、Alignment型の引数や変数に対しては上の3種類の文字列が入力補完として現れるという挙動になります。このハックによって、任意の文字列を受け入れることと特定の文字列の補完が出ることを両立できるのです。string & {}stringにすると| "left" ……の部分が消えてただのstringになってしまいます。このハックの挙動を保存するためにstring & {}stringにすることはできないのです。ハックではなく公式の方法を用意しようという議論もありますが、具体的な進展はないようです。

これを踏まえて、次のコードを見てみましょう。次のコードはTypeScript 4.8で挙動が変わります。

function nonNullable<T>(x: T): T & {} {
  if (x === null || x === undefined) {
    throw new Error("Huh?");
  }
  return x as T & {};
}

// TypeScript 4.7 では string & {} 型
// TypeScript 4.8 では string 型
const str = nonNullable<string>("");

このように、TypeScript 4.8ではT & {}Tstringに入ることでstring & {}になるような場合は、ただのstringにされます。一方で、明示的にstring & {}と書いた場合、前述のハックを維持するためにそのままになります。

TypeScript 4.8では型推論によりT & {}が生まれる機会が増えたため、本来不要なstring & {}のような型が生まれる可能性を消しているのでしょう。生まれてしまったハックは維持しつつもなるべく変な挙動を無くそうという配慮が感じられますね。

まとめと感想

この記事では、TypeScript 4.8で入る型の絞り込みの改善について説明しました。結果的には、NonNullable<T>の定義の改善が重要です。そのために必要な一連の修正が行われたと理解するのがよいでしょう。

ただ、個人的にはunknown{}に絞り込まれても嬉しい場面がそれほどありません。

自分はよく次のような関数を作ります。これはunknownを({}ではなく)Record<string, unknown>に絞り込みます。

function isNonNullish(value: unknown): value is Record<string, unknown> {
  return value !== null && value !== undefined;
}

こちらの方が、得られたオブジェクトに対して自由にプロパティアクセスができて便利です。ただ、TypeScriptではこのように「存在しないプロパティにアクセスできる」という挙動をデフォルトにする気は今のところは無いようです。それでもより快適なTypeScriptライフに一歩また近づきますね。

脚注
  1. ただし、unknownが実際にユニオン型として再定義されたというわけではありません。そうしてしまうとunion distributionの挙動にも影響を与えてしまうので、恐らくできないのでしょう。 ↩︎

GitHubで編集を提案

Discussion

ログインするとコメントできます