altJS、すなわち、JavaScriptの代わりとなる言語の筆頭である「TypeScript」。TypeScriptという言語名が示す通り、JavaScriptに「Type」、つまり、型の概念を持ち込んだものです。本連載では、このTypeScriptの型に関して、さまざまな方向から紹介していきます。前回は関数の型に関して、基本となる引数や戻り値の型、さらには関数そのものの型を紹介しました。今回は引き続き、関数の型を掘り下げていき、型ガードやオーバーロードなどを紹介します。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
連載の第2回では、ユニオン型変数、すなわち、複数のデータ型に対応した変数を紹介しました。このユニオン型は、関数の引数として指定できます。具体例を見ていきましょう。
例えば、名前を表す文字列、または、年齢を表す数値という引数を基に、何かメッセージを生成する関数createMessage()があるとします。この関数のシグネチャは、リスト1のようになります。
function createMessage(nameOrAge: string|number): string
このシグネチャのポイントは、引数のデータ型として「string|number」のようにユニオン型を指定したところです。こうすることで、リスト2のように、文字列、数値の両方を渡せるようになりました。
const msg1 = createMessage("田中太郎"); const msg2 = createMessage(25);
一方で、stringとnumber以外のデータ型の引数は渡せません。試しに、boolean型の引数を渡そうとすると、図1のようにエラーになります(以下、「Visual Studio Code」を利用)。
ここで、リスト2の実行結果として、msg1は「田中太郎さん!ようこそ!」、msg2は「25歳おめでとう!」になるとします。そのようにcreateMessage()関数を作ろうとするならば、当然、引数のデータ型がstringなのかnumberなのか、関数内で分岐させる仕組みが必要になります。その上で、string型なら、引数に「さん!ようこそ!」を追加した文字列を戻り値にします。一方、number型なら、「歳おめでとう!」を追加した文字列にします。
このように、データ型で分岐する際に活躍する演算子が、「typeof」です。typeofを用いたcreateMessage()関数はリスト3のようになります。
function createMessage(nameOrAge: string|number): string { let message = ""; if(typeof nameOrAge == "string") { // (1) message = `${nameOrAge}さん!ようこそ!`; } else { // (2) message = `${nameOrAge}歳おめでとう!`; } return message; }
リスト3(1)のif条件式の中で、引数nameOrAgeに対してtypeof演算子を適用しています。このtypeof演算子の演算結果は、以下のいずれかの文字列になり、それぞれが該当するデータ型を表します。
・string
・number
・bigint
・boolean
・symbol
・undefined
・object
・function
typeofを利用して型判定を行う構文は、次の通りです。
[構文1]typeofによる型判定 typeof 変数 == "データ型文字列"
前項で紹介したtypeof演算子は、TypeScript独自のものではなく、JavaScriptにもあります。しかし、TypeScriptではtypeofに別の利点があります。typeofで型判定を行った条件分岐ブロック内では、該当変数はその型に固定される、という利点です。これを、「型ガード」といいます。
例えば、リスト3の(1)の条件に合致するブロック内では、nameOrAgeはstring型として振る舞います。そのため、例えば文字数を取得するリスト4のコードは問題なく実行できます。
const length = nameOrAge.length;
一方で、掛け算を実行するリスト5のコードは、型ガードのため、図2のようにエラーになります。
const months = nameOrAge * 12;
一方、リスト3(2)のelseブロックでは、nameOrAgeはnumber型として振る舞うので、逆に、リスト4はエラーになり、リスト5は問題なく動作します(図3)。
typeofによる型ガードを使うと、引数などで、どうしても複数のデータ型をまとめて扱わなければならない場合でも、安心してコーディングできます。
ところで、typeof演算子の演算結果を見ると、オブジェクトが全てobject型になってしまいます。オブジェクトの型を区別したい場合は、typeofではなく、「instanceof」演算子を使います。ただし、typeofとは使い方が違います。
具体例を見てみましょう。例えば、リスト6のようなシグネチャの関数があるとします。
function addElement(element: number, list: number[]|Set<number>): void {
このaddElement()関数は、第1引数で受け取った数値を、第2引数で受け取ったリストオブジェクトに格納します。ただし、リストオブジェクトとして考えられるものは、配列(Arrayオブジェクト)とSetの2つあるため、第2引数をユニオン型として、「number[]|Set<number>」と記述しました。なお、「<number>」という記述は、第2回で紹介したように、ジェネリクス型指定であり、Setオブジェクトの各要素の型が数値(number)であることを表しています。
この第2引数であるlistに第1引数elementを追加しようとすると、追加メソッドが違うため、型判定が必要になります。一方で、オブジェクトの型判定ではtypeofは利用できません。そこで、リスト7のようなコードを使います。
function addElement(element: number, list: number[]|Set<number>): void { if(list instanceof Array) { // (1) list.push(element); } else { // (2) list.add(element); } }
リスト7の(1)がinstanceofを利用して型判定を行っているコードです。instanceofには次のような構文を使います。
[構文2]instanceof型判定 変数 instanceof オブジェクト型
(1)では第2引数listがArray、つまり配列かどうなのかを判定しています。ということは、当然、(2)以降はSetオブジェクトの場合です。
前項で紹介した型ガードは、instanceofでも有効です。リスト7(1)のifブロック内では、listは配列として振る舞います。そのため、Setオブジェクトにあるメソッドadd()は使えず、エラーになります(図4)。
同様に、elseブロック中で、配列のメソッドであるpush()を記述すると図5のようにエラーになります。
型ガードの応用として、TypeScriptでは、型判定を外部化することによって型を固定できる仕組みがあります。それを次に紹介します。
TypeScriptでコーディング中によく目にする問題があります。ある変数が「undefined、または、○○」という問題です。例えば、キーがnumber、値がstringのMapオブジェクトがあるとします。これに対してリスト8のようなコードを記述したとしましょう。なお、リスト8の「<number, string>」という記述もジェネリクス型指定であり、それぞれ、キーと値の型指定を表します。
const list = new Map<number, string>(); // (1) const element =list.get(345); // (2) const length = element.length // (3)
リスト8の(2)では、(1)で用意した空のMapオブジェクトlistに対して、get()メソッドを実行し、キーが345の要素を取得しています。当然、これは空であり、データ型としてはundefinedになります。一方、仮にキー345のデータが格納されていれば、string型になります。つまり、データがある場合とない場合とで、get()の戻り値elementのデータ型が変わってきます。そのため、(3)のコードが実行エラーになるのかどうか、elementがstringなのかundefinedなのかによって変わってきます。
この場合、elementをstringかどうか、すなわち、データが存在し、undefinedではないかどうかを判定し、判定後はstringで扱えるようにできれば便利です。TypeScriptにはこのような仕組みがあります(リスト9)。
function isNumber(element: string|undefined): element is string { // (1) return typeof element == "string" // (2) } const list = new Map<number, string>(); list.set(345, "こんにちは"); : if(isNumber(element)) { // (3) const length = element.length : }
ポイントは、(1)の関数isNumber()です。isNumber()では、Mapの各要素を引数として受け取ることを前提としています。そのため、型は「string|undefined」になっています。そして、この引数が、文字列かどうかを判定し、該当するならばtrue、そうでないならば、falseを戻り値とするようにしています。それが、(2)です。そして、この関数の最大の特徴は、戻り値の型を記述している部分です。通常ならば、戻り値はboolean型なので、リスト10のシグネチャになるはずです。
function isNumber(element: string|undefined): boolean
ところが、リスト9(1)では「element is string」になっています。このように記述した場合、戻り値がtrueの場合、以降のコードではelementに該当する変数は、stringとして扱う、という仕組みになっています。
この仕組みを利用して、リスト9の(3)のように、取得したelementに対して型チェックを行い、該当する場合は適切な処理を実行する、というコードを安全に記述できます。
これは、型を固定する仕組みであり、前節で紹介した型ガードのカスタマイズ版といえます。
最後に構文としてまとめておきます。
[構文3]型固定 function isデータ型(引数名: データ型|別のデータ型): 引数名 is データ型 { 引数がデータ型に該当するかのtrue/falseの値 }
さて、話をガラッと変えます。TypeScriptの関数には、JavaScriptの関数にない仕組みとして、「オーバーロード」というものがあります。これを紹介しましょう。
例えば、リスト1〜3で扱ったcreateMessage()関数として、別のものを考えます。ここでは、リスト11のような使い方ができるとします。
const msg1 = createMessage("田中太郎"); // (1) const msg2 = createMessage(35, 28); // (2)
msg1の内容は「田中太郎さん!ようこそ!」であり、msg2の内容は「計算結果は980です!」(35×28)になるとします。このような結果になるように処理内容を考えると、次のようになります。
・(A)引数として、名前を表す文字列1個を受け取り、その引数に「さん!ようこそ!」を追加した文字列を戻り値とする。
・(B)引数として、数値を2個受け取り、その数値の掛け算を行い、その結果を基に「計算結果は○○です!」という文字列を戻り値とする。
このそれぞれを、関数のシグネチャで記述すると、(A)の場合は、リスト12のようになります。
function createMessage(name: string): string;
一方、(B)の場合は、リスト13のようになります。
function createMessage(num1: number, num2: number): string;
これまでの知識で、このような関数を作ろうとするなら、リスト14のようなシグネチャを思い付くかもしれません。
function createMessage(nameOrNum1: number|string, num2?: number): string;
第1引数は文字列か数値を受け取るようにし、第2引数は数値型で省略できるようにするというシグネチャです。確かにこのシグネチャでも、動作はしますが、問題があります。というのは、リスト15のような利用法が可能になってしまうからです。
const msg = createMessage(35);
リスト11のように使う関数であれば、第1引数が数値の場合は、第2引数の数値も必須です。リスト14のシグネチャでは、この仕様を実現できません。このように、同一名称の関数に対して、引数の省略やユニオン型で対応できない場合、TypeScriptでは、関数のオーバーロードという方法が有効です。関数のオーバーロードとは、引数の違う同名関数を複数定義できる方法です。
では、関数のオーバーロードを使ってcreateMessage()関数を定義すると、リスト16のようになります。
function createMessage(name: string): string; // (1) function createMessage(num1: number, num2: number): string; // (2) function createMessage(nameOrNum1: string|number, num2?: number): string { // (3) let message = `${nameOrNum1}さん!ようこそ!`; if(typeof nameOrNum1 == "number") { // (4) const ans = nameOrNum1 * num2; message = `計算結果は${ans}です!`; } return message; }
リスト16(1)はまさにリスト12と同じであり、同様に、(2)はリスト13と同じです。このように、関数のオーバーロード構文では、利用される関数シグネチャのみを、まず記述します。この場合、波かっこブロック「{ }」は不要で、戻り値の型定義の後に、すぐセミコロンを記述し、文を終了します。リスト16では、シグネチャを2個定義していますが、もちろん、幾つでも定義できます。関数のオーバーロード定義において、この波かっこブロックのないシグネチャだけのコード部分を、「オーバーロードシグネチャ」といいます。
そして、これらオーバーロードシグネチャに続けて、(3)のように、全てのシグネチャを組み合わせたシグネチャを記載します。これは、まさにリスト14と同じあり、このシグネチャを、「実装シグネチャ」といいます。リスト16のオーバーロードシグネチャと実装シグネチャの関係を図にすると、図6のようになります。
図6のポイントは、オーバーロードシグネチャのどの引数パターンにも合致するように、実装シグネチャの引数を定義する必要があるということです。
そして、この実装シグネチャには、波かっこブロックを続けて、中に処理を記述します。この実装シグネチャの特徴は、どのオーバーロードメソッドのパターン、すなわち、リスト16の(1)のパターンでも、(2)のパターンでも、createMessage()を実行すると、この実装メソッドの波かっこブロック内の処理が実行される、ということです。そのため、リスト16(4)のように、引数の有無や型によって、内部で処理の分岐を行う必要があるのです。
さらに、もう1つ理解しておかなければならないことがあります。それは、オーバーロード関数では、実装シグネチャは直接呼び出されない、ということです。そのため、リスト15のように引数が数値1つだけのcreateMessage()を実行すると、図7のようにエラーになります。
同じく、文字列と数値を呼び出すと図8のエラーになります。
このように、オーバーロードを利用すると、複数の引数シグネチャに対応した同名関数を、安全に作成できます。
最後に、前回紹介したnever型の活用方法を紹介しておきましょう。
ここで、リスト7のaddElement()関数にもう一度登場してもらいましょう。この第2引数のlistの型として、リスト7では配列とSetオブジェクトの2種類が記載されています。これが、増えたとすると、当然処理ブロック内の条件分岐も追加しなければ、処理漏れが生じます。例えば、リスト17のようにMapを追加する場合を考えましょう。
function addElement(element: number, list: number[]|Set<number>|Map<number, number>): void
もちろん、このように関数シグネチャを直接変更する場合は、波かっこブロック内部の処理もその時点で変更するでしょうから、あまり問題になりません。一方で、本連載の後の回で扱うように、TypeScriptでは「number[]|Set<number>」の型記述を別に定義する方法があります。そうなると、引数の型定義と関数定義が別の箇所で記述されることになり、型定義だけ修正したものの、関数の修正を忘れる、という可能性が出てきます。
そのような場合に、波かっこブロック内の分岐処理の追記漏れを防ぐ方法があり、そこでneverが活躍します。具体的には、リスト18のようなコードです。
function addElement(element: number, list: ……): void { if(list instanceof Array) { : } else if(list instanceof Set) { // (1) : } else { const elseParam: never = list; // (2) throw new Error(); // (3) } }
リスト18のポイントは、リスト7ではelseブロックに記述していたlistの型がSetの場合の処理を分割し、(1)のようにelse ifとして、Setかどうかの評価を行っているところです。さらに、elseブロック内に(2)と(3)のコードを記載しています。(2)で第2引数listをnever型変数に代入します。その上で、(3)のエラー発生コードを記述しました。
実は、第2引数listが配列かSetのみの場合は、このelseブロックが実行されることはありません。ところが、listのデータ型を増やした瞬間に、elseブロックが実行される可能性が出てきます。そして、その場合、(2)のコードで、never型というあり得ない値に対して、例えば、Map型というあり得る値を代入しようとすることになり、コンパイルエラーになります。実際、addElement()をリスト17のシグネチャにした瞬間に、図9のようなエラー表示が現れます。
このエラーのおかげで、引数の型による分岐の追記漏れを防ぐことができます。
TypeScriptのTypeに注目し、あれこれ紹介する本連載の第4回はいかがでしたか? 今回はtypeofやinstanceofによる型チェックと型ガード、関数のオーバーロード、さらにはneverの活用法を紹介しました。
次回は、TypeScriptのクラスの型にまつわるあれこれを紹介します。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.