🕌

TypeScript 4.6で起こるタグ付きユニオンのさらなる進化

commits11 min read

この記事の公開時点ではTypeScript 4.5のBetaが出たばかりといったところですが、TypeScriptのリポジトリでは早くもTypeScript 4.6をターゲットにした改善が考えられています。おそらく、大きめの新機能であるためすでにBetaが出ている4.5は避けたのでしょう。この記事ではそのうちの一つである、タグ付きユニオンに対するさらなる進化をご紹介します。PRでいうと次のものです。

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

また、この変更によって、TypeScript 4.4, 4.5, 4.6と3連続でタグ付きユニオンが進化することになります。これらについてこの記事で紹介します。

TypeScriptにおけるタグ付きユニオン

せっかくなので、この記事ではTypeScriptでのタグ付きユニオンについて基本的なことも解説します。タグ付きユニオンは、他にも「直和型」など色々な呼び名がありますが、英語圏のTypeScript界隈ではdiscriminated union (直訳すると「判別ユニオン / 判別共用体」)という用語がよく使われるように思われます。

これは特殊な形のユニオン型であり、ユニオン型の各構成要素であるオブジェクト型が、それらが共通して持つプロパティ(タグ)を通じてランタイムに判別できるようなものを指します。最も典型的なのは、タグとして、各構成要素に異なるリテラル型(特に文字列のリテラル型)を与えるものです。

type OkResult<T> = {
  type: "ok";
  payload: T;
};
type ErrorResult = {
  type: "error";
  payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  if (result.type === "ok") {
    return result.payload;
  } else {
    throw result.payload;
  }
}

上の例では、Result<T>型がタグ付きユニオンであり、タグとなるのはOkResult<T>ErrorResultが共通して持つtypeプロパティです。関数unwrapResultでは、if文でタグをランタイムにチェックすることで、型の絞り込みを行なっています。このユニオン型の定義では、typeプロパティの値をチェックすることで実際の値がユニオン型の構成要素の2つのオブジェクト型のうちどちらであるのか判明するようになっています。具体的には、typeプロパティが"ok"であるならばresult.payloadT型となります。そうでなければ、result.payloadError型となります。

TypeScriptでは、ユニオン型の中でもこのように定義されたタグ付きユニオンには特に手厚いサポートがあり、型上で「または」を表す方法として重宝されています。

タグ付きユニオンと型の絞り込み(TS 4.4より前)

タグ付きユニオンの強みは型の絞り込みに対する優れたサポートです。これまでのTypeScriptでは、このような型の絞り込みは常にタグ付きユニオンを型に持つ変数に対して行われるものでした。上の例でいえば、絞り込まれるのは変数resultResult<T>というタグ付きユニオン型を持つ)でした。上の例は、result.type === "ok"というチェックを通過した時点でresultResult<T>からOkResult<T>型に絞り込まれます。よって、result.payloadT型になるでした。

絞り込み条件は、result.type === "ok"のように、絞り込み対象の変数が明示されており、それに対するプロパティアクセスとしてタグが指定され、さらにそれに対して===などで比較が行われていなければいけません。これがTypeScript 4.4より前の状況でした。

TypeScript 4.4での進化

TypeScript 4.4では、タグの値を事前に変数に入れることおよびタグに対する判別式を変数に入れることがサポートされ、これらの場合でも正しく型の絞り込みが行われるようになります。例えば、先ほどの例を次のように変更しても、TypeScript 4.4では動作します。

function unwrapResult<T>(result: Result<T>): T {
  const { type } = result;
  if (type === "ok") {
    // ちゃんと result が絞り込まれている
    return result.payload;
  } else {
    throw result.payload;
  }
}

また、次のように===の結果を変数に入れても正しく絞り込まれます。

function unwrapResult<T>(result: Result<T>): T {
  const isOk = result.type === "ok";
  if (isOk) {
    // ちゃんと result が絞り込まれている
    return result.payload;
  } else {
    throw result.payload;
  }
}

当該のPRはこちらです。

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

TypeScript 4.5での進化

TypeScript 4.5では、タグがテンプレートリテラル型である場合がサポートされました。やや人工的ですが次のように例を変えてみましょう。

type OkResult<T> = {
  type: `ok_${string}`;
  payload: T;
};
type ErrorResult = {
  type: `error_${string}`;
  payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapFooResult<T>(result: Result<T>): T {
  if (result.type === "ok_foo") {
    // ここでは result は OkResult<T> 型
    return result.payload;
  } else {
    // ここでは Result<T> 型のまま
    throw result.payload;
  }
}

Result<T>のタグが固定の文字列リテラル型ではなくテンプレートリテラル型になりました。こうすると、OkResult<T>のタグ(typeプロパティ)は「ok_で始まる任意の文字列」という意味になり、同様にErrorResultは「error_で始まる任意の文字列」になります。Result<T>typeプロパティとして考えられるのは、"ok_foo""ok_pikachu""error_http"といった文字列です。

上の例のunwrapFooResult関数では、その中でもタグが特定の"ok_foo"という値であるかどうかを確かめます。TypeScript 4.5からは、こうすることでこのif文の中(thenブランチの中)ではresultOkResult<T>型に絞り込まれます。なぜなら、"ok_foo"`ok_${string}`型に当てはまる値なのでresultOkResult<T>である可能性がある一方で、"ok_foo"`error_${string}`型に当てはまる値ではないため、resultErrorResultである可能性が無くなるからです。

ただし、この場合、elseブランチではresultResult<T>型のままである(ErrorResultに絞り込まれたりはしない)ことに注意してください。これは、result.typeok_pikachuの場合など、ok_foo以外だが依然としてOkResult<T>に属しているケースがあるからです。

この機能を追加するPRはこちらです。

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

TypeScript 4.6 での進化

では、いよいよTypeScript 4.6での進化を解説します。これまでは全てresult.payloadとして肝心の中身にアクセスしていましたが、TypeScript 4.6では、4.4の例をさらに一歩進めて次のような形で絞り込みを行うことができます。

type OkResult<T> = {
  type: "ok";
  payload: T;
};
type ErrorResult = {
  type: "error";
  payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  // payload はここでは T | Error 型
  const { type, payload } = result;
  if (type === "ok") {
    // payload はこの中では T 型
    return payload;
  } else {
    throw payload;
  }
}

これまでの「あくまで型の絞り込みが行われる対象はresultであるという」という常識を覆し、あらかじめ変数に入れておいたpayloadが絞り込まれるようになりました。このように、typeに対するチェックを行うことで、それとは別の変数であるpayloadに対して絞り込みが発生するというのがこれまでにない画期的な事象です。TypeScript 4.6より前はpayloadの方は代入された瞬間のT | Error固定であり、その後別の変数に対してチェックを行なっても変わることはありませんでした。

ただし、この機能が働くためにはタグが入った引数 (type) と中身が入った引数 (payload) が同じ分割代入で作られることが必要です。つまり、次のように変えると絞り込みは行われなくなります。

function unwrapResult<T>(result: Result<T>): T {
  const { type } = result;
  const { payload } = result;
  if (type === "ok") {
    // これでは絞り込まれない!
    return payload;
  } else {
    throw payload;
  }
}

同様に、次のようにするのもうまくいきません。

function unwrapResult<T>(result: Result<T>): T {
  const type = result.type;
  const payload = result.payload;
  if (type === "ok") {
    // これでも絞り込まれない!
    return payload;
  } else {
    throw payload;
  }
}

同時に分割代入された変数でなければいけない理由は主に2つ考えられます。一つは、そのほうが変数同士の関係をトラックしやすいという実装上の問題です。もう一つは、オブジェクトの中身が変更可能であるゆえに、別々のタイミングでオブジェクトから取得されたプロパティ同士を関係させられない(あるいは、関係させて良いかどうかを追加でチェックしなければならずコストがかかる)ことが挙げられます。

ちなみに、次のように関数の引数を直に分割代入するのはOKです。むしろ、この機能の主要な需要は、このようにすることで引数オブジェクトを一度変数に入れる必要が無くなるという点にあるかもしれません。TypeScript 4.6より前では、payloadではなく必ずresultが絞り込まれるという点からこれは不可能でした。

function unwrapResult<T>({ type, payload }: Result<T>): T {
  // payload はここでは T | Error 型
  if (type === "ok") {
    // payload はこの中では T 型
    return payload;
  } else {
    throw payload;
  }
}

このように、TypeScript 4.6の新機能は、絞り込み対象のオブジェクト(タグ付きユニオン型を持つオブジェクト)から事前に(絞り込みを行う前に)取得したプロパティが入った変数(payload)が、同じ分割代入に由来する別の変数(type)にチェックを行うことで、事後的に絞り込まれるという点に特徴があります。

当該のPRは記事冒頭でも紹介しましたが、再掲します。

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

TypeScript 4.6でもまだできないこと

ただし、これであらゆる場合を分割代入で解決できるようになったかと言うと、そうでもありません。先ほどまで取り扱ってきたタグ付きユニオンにはある特徴がありました。それは、OkResult<T>の場合もErrorResultの場合も、中身を表すプロパティの名前がpayloadという共通した名前なのです。

個人的には、次のように別々の名前にするのが好みです。

type OkResult<T> = {
  type: "ok";
  value: T;
};
type ErrorResult = {
  type: "error";
  error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  if (result.type === "ok") {
    return result.value;
  } else {
    throw result.error;
  }
}

この例では、従来payloadだったプロパティがOkResult<T>ではvalueという名前に、ErrorResultではerrorという名前になっています。上の例では従来型の方法で絞り込みを行なっているので、絞り込みの後にresult.valueresult.errorにアクセスすることは問題なくできます。if文のthenブランチではresultOkResult<T>に絞り込まれていて、この型にはvalueプロパティが存在しているからです。

しかし、このようなタグ付きユニオンはTypeScript 4.6の恩恵を受けることができません。次のようにするとコンパイルエラーが出てしまいます。

type OkResult<T> = {
  type: "ok";
  value: T;
};
type ErrorResult = {
  type: "error";
  error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  const { type, value, error } = result;
  if (type === "ok") {
    return value;
  } else {
    throw error;
  }
}

コンパイルエラーは次のような内容で、絞り込みの前のresultからvalueerrorを取得することはできないというものです。

error TS2339: Property 'value' does not exist on type 'Result<T>'.

12   const { type, value, error } = result;
                   ~~~~~

error TS2339: Property 'error' does not exist on type 'Result<T>'.

12   const { type, value, error } = result;
                          ~~~~~

つまり、Result<T>の段階ではこのオブジェクトにvalueerrorが確実に存在するとは言えず、存在しないプロパティにアクセスすることは典型的なミスですからコンパイルエラーにより防がれるのです。

このことにより、その人の設計にもよりますが、TypeScript 4.6の新機能の恩恵をただちに受けられるケースはあまり多くないのではないでしょうか。

今後の展望としては3つの方向性が考えられます。一つは、TypeScript 4.6の恩恵を受けられるように、タグ付きユニオンのそれぞれの構成要素に存在するプロパティの名前を(payloadのように)共通化するというものです。しかし、個人的にはあまりこの方向性は好みではありません。全ての場合に共通するプロパティの名前となると、それこそpayloadのような抽象的な名前にせざるを得ず、命名があまり良くないと感じるからです。

二つ目は、次のように?: undefinedというプロパティ定義を追加することで共通のプロパティ名化することです。

type OkResult<T> = {
  type: "ok";
  value: T;
  error?: undefined;
};
type ErrorResult = {
  type: "error";
  value?: undefined;
  error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  const { type, value, error } = result;
  if (type === "ok") {
    return value;
  } else {
    throw error;
  }
}

こうするとうまく動いてくれるようになります。現状(TS 4.6で)可能な方法では一番優れていますが、筆者としては完璧ではないかなと考えています。というのも、次のようにそもそもtypeに頼らずにロジックを書けるようになってしまいます。これは一見よいことのように見えますが、筆者の経験ではこれはタグ付きユニオンの意図を無視したロジックの温床となってしまうので、あまり推奨できない部分があります。ちゃんとtypeを見て分岐するロジックを書いて欲しいです。

function unwrapResult<T>(result: Result<T>): T {
  const { value, error } = result;
  if (value !== undefined) {
    return value;
  } else {
    throw error;
  }
}

最後の一つは、これは筆者の妄想なのですが、存在しないかもしれないプロパティにアクセスすることも一旦許してしまって、適切な絞り込みが行われる前に使おうとするとコンパイルエラーにするということも考えられます。

type OkResult<T> = {
  type: "ok";
  value: T;
};
type ErrorResult = {
  type: "error";
  error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;

function unwrapResult<T>(result: Result<T>): T {
  // これはエラーにならない(妄想)
  const { type, value, error } = result;

  // ここで value を使おうとするとコンパイルエラーになる(妄想)
  console.log(value);
  if (type === "ok") {
    // ここで value を使うのはOK(妄想)
    return value;
  } else {
    throw error;
  }
}

個人的にはこの方向性になってくれると嬉しいなと思います。TypeScript 4.4から4.6までの流れによってタグ付きユニオンに対する分割代入をサポートする方向性が見出されていますから、その延長として上記のようなことも考えられます。また、すでに「適切な操作を行う前にアクセスするとエラーになる変数」という概念自体は既存のTypeScriptに存在しています。それはletによる変数です。

let foo: number;

// ここで foo を使おうとするとエラー(未代入なので)
console.log(foo);

foo = 0;

// ここで foo を使うのはOK
console.log(foo);

これと同じ機構を使えば「適切な絞り込みが行われる前にアクセスするとエラーになる変数」も不可能ではないのではないかと妄想しています。これはこれまで見かけたことがなかったのでissue化しました。いいねと思ったら👍の応援をよろしくお願いします。

https://github.com/microsoft/TypeScript/issues/46318

まとめ

この記事では、TypeScript 4.4から4.6で連続して起こった(あるいは起こる予定の)タグ付きユニオンのサポート強化について解説しました。タグ付きユニオンはTypeScriptで利用可能な最も強力な設計パターンの一つであり、このように機能強化が続いているのはとても嬉しいですね。

GitHubで編集を提案

Discussion

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