フリーダムの日記

GIS(地理情報システム)を中心に技術的なことを書いています。

プログラミング TypeScript:第 4 章 関数について

はじめに

本記事では「プログラミング TypeScript ―スケールする JavaScript アプリケーション開発」について自身の学習も含めて、数回に渡って紹介しています。

プログラミングTypeScript ―スケールするJavaScriptアプリケーション開発

プログラミングTypeScript ―スケールするJavaScriptアプリケーション開発

  • 作者:Boris Cherny
  • 発売日: 2020/03/16
  • メディア: 単行本(ソフトカバー)

前回は、プログラミング TypeScript:第 3 章 型について紹介しました。

freedom-tech.hatenablog.com

今回は、第 4 章の関数について紹介していきます。 この章で取り上げるテーマは次のようなものです。

また、学習用にサンプルプログラミングも Github で紹介しています。 github.com

4.1 TypeScript での関数の宣言と呼び出し方法

JavaScript では、関数は第一級のオブジェクトです。そのため、他のオブジェクトとまったく同じように扱うことができます。それらを変数に割り当てたり、他の関数から返したり、オブジェクトやプロトタイプに割り当てたり、それらのプロパティを追加したり、それらのプロパティを読み取ったり、といったことができます。JavaScript では関数を使って行えることがたくさんあり、TypeScript はそのすべてのものを、豊富な型システムを使って実現します。 TypeScript の関数の例をみていきましょう。

function add(a: number, b: number) {
  return a + b
}

通常は、関数のパラメーター(この例では a と b)を明示的にアノテートします(パラメーターの型を明示的に指定します)。TypeScript は、関数の本体全体を通じて常に型を推論しますが、文脈から型を推論できるいくつかの特別なケースを除いて、多くの場合、パラメーターについては型を推論しません。戻り値の型については推論しますが、希望すれば、これも明示的にアノテートすることができます。

function add(a: number, b: number): number {
  return a + b
}

先ほどの例は、名前付き関数 (named function) の構文を使って関数を宣言していますが、JavaScript と TypeScript には、関数を宣言するための方法が少なくとも5つあります。

// 名前付き関数
function greet(name: string) {
  return 'hello' + name
}

// 関数式
let greet2 = function(name: string) {
  return 'hello' + name
}

// アロー関数式
let greet3 = (name: string) => {
  return 'hello' + name
}

// アロー関数式の省略記法
let greet4 = (name: string) =>
  'hello' + name

// 関数コンストラクター
let greet5 = new Function('name', 'return "hello" + name')

関数コンストラクター(まったく安全ではないので、蜂に追いかけられているのでなければ、使用すべきではありません)を除いて、これらのすべての構文は TypeScript によって型安全な方法でサポートされており、それらはすべて、パラメーターと戻り値の型に関して同じルール(パラメーターは、通常は型アノテーションが必須で、戻り値の型は、型アノテーションが省略可能)に従います。

用語についてのおさらい

  • パラメーター(parameter)は、関数が実行されるために必要とするデータであり、関数宣言の一部として宣言されます。仮パラメーター (formal parameter) とも呼ばれます。
  • 引数 (argument) は、関数を呼び出すときに (関数に) 渡すデータです。実パラメーター (actual parameter) とも呼ばれます。

TypeScript で関数を呼び出すときには、付加的な型情報を与える必要はありません。単にいくつかの引数を渡せば、TypeScript は仕事に取り掛かり、あなたの引数が関数のパラメーターの型と互換性があるかどうかチェックします。

add(1, 2) // 3 と評価されます。
greet('Crystal') // 'hello Crystal' と評価されます。

もちろん、引数を忘れたり、間違った型の引数を渡したりすれば、TypeScript はすぐにそれを指摘します。

add(1) // エラー TS2554: 2 個の引数が必要ですが、1個指定されました。
add(1, 'a') // エラー TS2345: 型 '"a"' の引数を型 'number' の
            // パラメーターに割り当てることはできません。

4.2 呼び出しシグネチャ

関数そのものの完全な型を表現する方法を見ていきましょう。 前に出てきた add をもう一度見てみましょう。これは次のようなものでした。

function add(a: number, b: number): number {
  return a + b
}

add の型は何でしょうか?add は関数なので、その型は・・・・

Function

Function型は、ほとんどの場合にあなたが使いたいと望むものではありません。objectがすべてのオブジェクトを表現するように、Functionはすべての関数のための包括的な型であり、それが型付けする特定の関数については何も教えてくれません。 ではほかに、どのように add を型付けすることができるのでしょうか?add は2つの number を取り、number を返す関数です。TypeScript では、その型を次のように表現できます。

 (a: number, b: number) => number

これが、関数の型について TypeScript の構文、すなわち 呼び出しシグネチャ (call signature) です。型シグネチャ (type signature)と呼ばれることもあります。アロー関数とよく似ていると感じたかもしれません。これは意図的なものです。この構文は、関数を引数として渡す場合や、関数を別の関数から返す場合に、それらを型付けするために使います。

4.3 オーバーロードされた関数の型

多くのプログラミング言語では、何らかのパラメーターのセットを取り、何らかの戻り値の型を持つ関数をいったん宣言したら、正確にそのパラメーターのセットを使ってその関数を呼び出し、常に同じその型の戻り値を受け取ることになります。JavaScript はとても動的なので、特定の関数を呼び出すために複数の方法が存在するのが、よくあるパターンです。それだけでなく、出力される型が、入力される引数の型によって変わることさえあるのです。
TypeScript はこのダイナミズム---オーバーロードされた関数の宣言と、入力型によって変わる関数の出力型---を、その静的な型システムを使って具現化します。この言語機能を当然のものと思われるかもしれませんが、型システムにとっては実に高度な機能なのです。 オーバーロードされた関数のシグネチャを使うと、実に表現豊かな API を設計できます。たとえば、旅行を予約するための API 設計をしてみましょう。これを Reserve と呼ぶことにします。

 type Reserve = {
         (from: Date, to: Date, destination: string): Reservation
 }

次に、Reserve の実装の概略を作成しましょう。

 let reserve: Reserve = (from, to, destination) => {
      // ....
 }

バリへの旅行を予約したいユーザーは、from(出発日) と to(帰着日)、および destination(目的地) として "Bali" を指定して、この reserveAPI を呼び出します。
日帰り旅行もサポートするように、この API を作り変えましょう。

 type Reserve = {
         (from: Date, to: Date, destination: string): Reservation
         (from: Date, destination: string): Reservation
 }

このコードを実行しようとすると、Reserve を実装している場所で、TypeScript がエラーとなります。これは、呼び出しシグネチャオーバーロードが TypeScript でどのように処理されるかによるものです。

Reserve の例については、reserve関数を次のように書き換えることができます。

 type Reserve = {
         (from: Date, to: Date, destination: string): Reservation
         (from: Date, destination: string): Reservation
 }let reserve: Reserve = (
    from: Date,
    toOrDestination: Date | string,
    destination?: string
 ) => {// ...
 }

オーバーロードされた関数の 2 つのシグネチャを宣言します。 ② 実装のシグネチャは、2つのオーバーロードシグネチャを手動で結合した結果です(つまり、「シグネチャ1|シグネチャ2」を手動で計算します)。

 // 誤り
 type Reserve = {
         (from: Date, to: Date, destination: string): Reservation
         (from: Date, destination: string): Reservation
         (from: Date, toOrDestination: Date | string,
           destination?: string): Reservation
 } 

reserve は、2つの方法のうちのどちらかで呼び出されるので、reserve を実装するときに、それがどのように呼び出されたかをチェック済であることを TypeScript に示す必要があります。

 let reserve: Reserve = (
    from: Date,
    toOrDestination: Date | string,
    destination?: string
 ) => {  
    if (toOrDestination instanceof Date && destination !== undefined) {
        // 宿泊旅行を予約する
    } else if (typeof toOrDestination === 'string') {
        // 日帰り旅行を予約する
    }
 }

練習問題

1.TypeScript は、関数の型シグネチャのうち、どの部分を推論するのでしょうか?パラメーターでしょうか、戻り値の型でしょうか、それともその両方でしょうか?

/ TypeScript は常に関数の戻り値の型を推論します。文脈から推論できる場合は、関数のパラメーター型を推論することもあります(たとえば、その関数がコールバックの場合)。/

2.JavaScript の arguments オブジェクトは型安全でしょうか?もし、そうでないとすると、代わりに何が使えるでしょうか?

/* arguments は型安全はありません。代わりに、次のようにレストパラメーターを使用するべきです。

変更前:function f() { console.log(arguments) } 変更後:function f(...args: unknown[]) { console.log(args) } */

3.すぐに出発する旅行を予約する機能が欲しいとします。オーバーロードされた reserve関数を、3番目の呼び出しシグネチャを作成して書き換えてください。このシグネチャは、目的地 (destination) だけを取り、明示的な出発日 (from) は取りません。この新しいオーバーロードされたシグネチャをサポートするようにreserveの実装を書き換えてください。

type Reservation = unknown

type Reserve = {
    (from: Date, to: Date, destination: string): Reservation
    (from: Date, destination: string): Reservation
    (destination: string): Reservation
}

let reserve: Reserve = (
   fromOrDestination: Date | string,
   toOrDestination?: Date | string,
   destination?: string
) => {
   if (
     fromOrDestination instanceof Date &&
     toOrDestination instanceof Date &&
     destination !== undefined
   ) {
       // 宿泊旅行を予約する
   } else if (
      fromOrDestination instaneof Date &&
      typeof toOrDestination === 'string'
   ) {
      // 日帰り旅行を予約する
   } else if (typeof fromOrDestination === 'string') {
     // すぐに出発する旅行を予約する
   }
}

4.難問callの実装を2番目の引数がstringである関数について「だけ」機能するように書き換えてください。そうでない関数を渡すと、コンパイル時にエラーとなるようにします。

function call<T extends [unknown, string, ...unknown[]], R>(
   f: (...args: T) => R,
): R {
   return f(...args)
}

function fill(length: number, value: string): string[] {
   return Array.from({length}, () => value)
}

call(fill, 10, 'a') // string[]

5.型安全なアサーション関数、is を実装してください。型で概略を記述することからはじめます。これは、完成したら、次のように使えるものです。

// string  と string を比較します。
is('string', 'otherstring') // false

// boolean  と boolean を比較します。
is(true, false) // false

// number  と number を比較します。
is(42, 42) // true

// 異なる型同士を比較すると、コンパイルエラーになります。
is(10, 'foo')  // エラー TS2345: 型 '"foo"' の引数を型 'number' の
                     // パラメーターに割り当てることはできません

// [難問]任意の数の引数を渡せるようにします
is([1], [1, 2], [1, 2, 3]) // false

function is<T>(a: Tm ...b: [T, ...T[]]): boolean {
   return b.every(_ => _ === a)
}

さいごに

今回は内容が多くて、内容も濃く、難しい内容でした。細かい内容は実際に書物を見ていただければと思います。最後は練習問題でどんな内容を紹介していたのかが分かるかと思います。次回はクラスとインターフェースです。