TypeScriptでの動的静的入力

公開: 2022-03-10
簡単な要約↬この記事では、共用体型、条件付き型、テンプレートリテラル型、ジェネリックなど、TypeScriptのより高度な機能のいくつかを見ていきます。 ほとんどのバグが発生する前にキャッチできるように、最も動的なJavaScriptの動作を形式化したいと考えています。 TypeScriptのすべての章からのいくつかの学習を50レッスンで適用します。これは、2020年後半にSmashing Magazineで公開した本です。詳細については、ぜひチェックしてください。

JavaScriptは本質的に動的プログラミング言語です。 私たち開発者は、わずかな労力で多くのことを表現でき、言語とそのランタイムは、私たちが何をしようとしていたかを理解します。 これがJavaScriptを初心者にとても人気のあるものにし、経験豊富な開発者を生産的にします! ただし、注意点があります。注意が必要です。 間違い、タイプミス、正しいプログラムの振る舞い:それの多くは私たちの頭の中で起こります!

次の例を見てください。

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { res.status(20).send({ message: "Got you, user " + req.params.userId }); } })

ルート(またはパス)を定義し、URLが要求された場合にコールバックを実行できるhttps://expressjs.com/スタイルのサーバーがあります。

コールバックは2つの引数を取ります。

  1. requestオブジェクト。
    ここでは、使用されているHTTPメソッド(GET、POST、PUT、DELETEなど)と、入力される追加のパラメーターに関する情報を取得します。この例では、 userIDは、ユーザーのIDを含むパラメーターuserIDにマップする必要があります。
  2. responseまたはreplyオブジェクト。
    ここでは、サーバーからクライアントへの適切な応答を準備します。 正しいステータスコード(メソッドstatus )を送信し、JSON出力をネットワーク経由で送信します。

この例で見られるものは大幅に単純化されていますが、私たちが何をしているのかについては良い考えを与えてくれます。 上記の例にもエラーがたくさんあります! 見てください:

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { /* Error 1 */ res.status(20).send({ /* Error 2 */ message: "Welcome, user " + req.params.userId /* Error 3 */ }); } })

ああすごい! 3行の実装コードと3つのエラー? 何が起きたの?

  1. 最初のエラーは微妙です。 GETリクエスト(したがってapp.get )をリッスンすることをアプリに通知しますが、リクエストメソッドがPOSTの場合にのみ何かを実行します。 アプリケーションのこの特定の時点では、 req.methodPOSTにすることはできません。 そのため、予期しないタイムアウトが発生する可能性のある応答を送信することはありません。
  2. ステータスコードを明示的に送信するのは素晴らしいことです。 ただし、 20は有効なステータスコードではありません。 クライアントはここで何が起こっているのか理解できないかもしれません。
  3. これは私たちが送り返したい応答です。 解析された引数にアクセスしますが、平均的なタイプミスがあります。 userIdではなくuserIDです。 すべてのユーザーは「ようこそ、ユーザーは未定義です!」と挨拶されます。 あなたが野生で間違いなく見た何か!

そして、そのようなことが起こります! 特にJavaScriptで。 私たちは表現力を獲得します-一度はタイプについて気にする必要はありませんでした-しかし私たちがしていることに細心の注意を払う必要があります。

これは、JavaScriptが動的計画法言語に慣れていないプログラマーから多くの反発を受ける場所でもあります。 彼らは通常、起こりうる問題を指摘し、エラーを事前にキャッチするコンパイラを持っています。 すべてが正しく機能することを確認するために頭の中でしなければならない余分な作業の量に眉をひそめると、彼らはスヌーピーとして外れる可能性があります。 JavaScriptには型がないとさえ言われるかもしれません。 これは真実ではありません。

TypeScriptのリードアーキテクトであるAndersHejlsbergは、MS Build 2017の基調講演で、次のように述べています。 それを形式化する方法はありません」。

そしてこれがTypeScriptの主な目的です。 TypeScriptは、JavaScriptコードをあなたよりもよく理解したいと考えています。 また、TypeScriptが意味を理解できない場合は、追加の型情報を提供することで支援できます。

ジャンプした後もっと! 以下を読み続けてください↓

基本的なタイピング

そして、これが私たちが今やろうとしていることです。 Expressスタイルのサーバーからgetメソッドを取得し、十分なタイプ情報を追加して、できるだけ多くのカテゴリのエラーを除外できるようにします。

まず、いくつかの基本的なタイプ情報から始めます。 get関数を指すappオブジェクトがあります。 get関数は、文字列であるpathとコールバックを取ります。

 const app = { get, /* post, put, delete, ... to come! */ }; function get(path: string, callback: CallbackFn) { // to be implemented --> not important right now }

stringは基本的ないわゆるプリミティブ型ですが、 CallbackFnは明示的に定義する必要のある複合型です。

CallbackFnは、次の2つの引数を取る関数型です。

  • req 、タイプはServerRequestです
  • タイプServerReplyreply

CallbackFnvoid返します。

 type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;

ServerRequestは、ほとんどのフレームワークで非常に複雑なオブジェクトです。 デモンストレーションの目的で簡略化されたバージョンを作成します。 "GET""POST""PUT""DELETE"などのmethod文字列を渡します。これには、 paramsレコードもあります。 レコードは、キーのセットをプロパティのセットに関連付けるオブジェクトです。 今のところ、すべてのstringキーをstringプロパティにマップできるようにします。 これは後でリファクタリングします。

 type ServerRequest = { method: string; params: Record<string, string>; };

ServerReplyの場合、実際のServerReplyオブジェクトにはさらに多くの機能があることを知って、いくつかの関数をレイアウトします。 send関数は、送信するデータでオプションの引数を取ります。 また、 status機能でステータスコードを設定することも可能です。

 type ServerReply = { send: (obj?: any) => void; status: (statusCode: number) => ServerReply; };

それはすでに何かであり、いくつかのエラーを除外することができます。

 app.get("/api/users/:userID", function(req, res) { if(req.method === 2) { // ^^^^^^^^^^^^^^^^^ Error, type number is not assignable to string res.status("200").send() // ^^^^^ Error, type string is not assignable to number } })

ただし、間違ったステータスコードを送信する可能性があり(任意の数が可能)、可能なHTTPメソッドについての手がかりがありません(任意の文字列が可能です)。 タイプを改良しましょう。

小さいセット

プリミティブ型は、その特定のカテゴリのすべての可能な値のセットとして表示できます。 たとえば、 stringには、JavaScriptで表現できるすべての可能な文字列が含まれ、 numberには、2倍の浮動小数点精度を持つすべての可能な数値が含まれます。 booleanには、 truefalseのすべての可能なブール値が含まれます。

TypeScriptを使用すると、これらのセットをより小さなサブセットに絞り込むことができます。 たとえば、HTTPメソッドで受け取ることができるすべての可能な文字列を含むタイプMethodを作成できます。

 type Methods= "GET" | "POST" | "PUT" | "DELETE"; type ServerRequest = { method: Methods; params: Record<string, string>; };

Methodは、大きなstringセットの小さなセットです。 Methodは、リテラル型の共用体型です。 リテラル型は、特定のセットの最小単位です。 リテラル文字列。 リテラル数。 あいまいさはありません。 ただの"GET"です。 それらを他のリテラル型と結合して、より大きな型のサブセットを作成します。 stringnumberの両方のリテラル型、または異なる複合オブジェクト型を使用してサブセットを作成することもできます。 リテラル型を組み合わせて結合する可能性はたくさんあります。

これは、サーバーのコールバックにすぐに影響します。 突然、これら4つの方法(または必要に応じてそれ以上)を区別し、コード内のすべての可能性を使い果たすことができます。 TypeScriptは私たちを導きます:

 app.get("/api/users/:userID", function (req, res) { // at this point, TypeScript knows that req.method // can take one of four possible values switch (req.method) { case "GET": break; case "POST": break; case "DELETE": break; case "PUT": break; default: // here, req.method is never req.method; } });

TypeScriptは、作成するすべてのcaseステートメントで、使用可能なオプションに関する情報を提供できます。 自分で試してみてください。 すべてのオプションを使い果たした場合、TypeScriptはdefaultのブランチでこれが発生することneverを通知します。 これは文字通りnever型です。つまり、処理する必要のあるエラー状態に達している可能性があります。

これは、エラーが少ないカテゴリの1つです。 これで、使用可能なHTTPメソッドが正確にわかりました。

statusCodeが取ることができる有効な番号のサブセットを定義することにより、HTTPステータスコードに対して同じことを行うことができます。

 type StatusCode = 100 | 101 | 102 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 598 | 599; type ServerReply = { send: (obj?: any) => void; status: (statusCode: StatusCode) => ServerReply; };

タイプStatusCodeも、共用体タイプです。 これにより、別のカテゴリのエラーを除外します。 突然、そのようなコードは失敗します:

 app.get("/api/user/:userID", (req, res) => { if(req.method === "POS") { // ^^^^^^^^^^^^^^^^^^^ 'Methods' and '"POS"' have no overlap. res.status(20) // ^^ '20' is not assignable to parameter of type 'StatusCode' } })
そして、私たちのソフトウェアははるかに安全になります! しかし、もっとできることがあります!

ジェネリックスを入力してください

app.getを使用してルートを定義すると、可能なHTTPメソッドは"GET"のみであることが暗黙的にわかります。 しかし、型の定義では、ユニオンのすべての可能な部分をチェックする必要があります。

可能なすべてのHTTPメソッドのコールバック関数を定義できるため、 CallbackFnの型は正しいですが、明示的にapp.getを呼び出す場合は、型に準拠するためにのみ必要な追加の手順をいくつか保存すると便利です。

TypeScriptジェネリックが役立ちます! ジェネリックスは、静的型から最も動的な動作を引き出すことができるTypeScriptの主要な機能の1つです。 TypeScript in 50 Lessonsでは、最後の3つの章で、ジェネリックスのすべての複雑さとそれらの独自の機能を掘り下げます。

今知っておく必要があるのは、セット全体ではなくMethodsの一部を指定できるようにServerRequestを定義したいということです。 そのために、関数で行うのと同じようにパラメーターを定義できる一般的な構文を使用します。

 type ServerRequest<Met extends Methods> = { method: Met; params: Record<string, string>; };

これが起こることです:

  1. ServerRequestは、山括弧で示されているように、ジェネリック型になります
  2. MethodsタイプのサブセットであるMetと呼ばれるジェネリックパラメーターを定義します
  3. このジェネリックパラメーターをジェネリック変数として使用して、メソッドを定義します。

また、ジェネリックパラメーターの命名に関する私の記事を確認することをお勧めします。

この変更により、重複することなく、さまざまなServerRequestを指定できます。

 type OnlyGET = ServerRequest<"GET">; type OnlyPOST = ServerRequest<"POST">; type POSTorPUT = ServerRquest<"POST" | "PUT">;

ServerRequestのインターフェイスを変更したため、 CallbackFnget関数など、 ServerRequestを使用する他のすべてのタイプに変更を加える必要があります。

 type CallbackFn<Met extends Methods> = ( req: ServerRequest<Met>, reply: ServerReply ) => void; function get(path: string, callback: CallbackFn<"GET">) { // to be implemented }

get関数を使用して、ジェネリック型に実際の引数を渡します。 これはMethodsのサブセットだけではなく、処理しているサブセットを正確に把握しています。

これで、 app.getを使用すると、 req.methodの可能な値のみが得られます。

 app.get("/api/users/:userID", function (req, res) { req.method; // can only be get });

これにより、 app.getコールバックを作成するときに、 "POST"などのHTTPメソッドが使用可能であると想定されなくなります。 この時点で何を扱っているかは正確にわかっているので、それをタイプに反映させましょう。

request.methodが適切に入力され、実際の状況を表すようにするために、すでに多くのことを行いました。 Methodsの共用体型をサブセット化することで得られる優れた利点の1つは、タイプセーフな汎用コールバック関数をapp.get外部に作成できることです。

 const handler: CallbackFn<"PUT" | "POST"> = function(res, req) { res.method // can be "POST" or "PUT" }; const handlerForAllMethods: CallbackFn<Methods> = function(res, req) { res.method // can be all methods }; app.get("/api", handler); // ^^^^^^^ Nope, we don't handle "GET" app.get("/api", handlerForAllMethods); // This works

パラメータの入力

まだ触れていないのは、 paramsオブジェクトの入力です。 これまでのところ、すべてのstringキーにアクセスできるレコードを取得しています。 それをもう少し具体的にすることが今の私たちの仕事です!

これを行うには、別のジェネリック変数を追加します。 1つはメソッド用、もう1つはRecord内の可能なキー用です。

 type ServerRequest<Met extends Methods, Par extends string = string> = { method: Met; params: Record<Par, string>; };

ジェネリック型変数Parは、 string型のサブセットにすることができ、デフォルト値はすべての文字列です。 これで、 ServerRequestにどのキーが必要かを伝えることができます。

 // request.method = "GET" // request.params = { // userID: string // } type WithUserID = ServerRequest<"GET", "userID">

get関数とCallbackFn型に新しい引数を追加して、要求されたパラメーターを設定できるようにします。

 function get<Par extends string = string>( path: string, callback: CallbackFn<"GET", Par> ) { // to be implemented } type CallbackFn<Met extends Methods, Par extends string> = ( req: ServerRequest<Met, Par>, reply: ServerReply ) => void;

Parを明示的に設定しない場合、 Parのデフォルトはstringであるため、タイプは以前と同じように機能します。 ただし、これを設定すると、突然req.paramsオブジェクトの適切な定義が得られます。

 app.get<"userID">("/api/users/:userID", function (req, res) { req.params.userID; // Works!! req.params.anythingElse; // doesn't work!! });

それは素晴らしいことです! ただし、改善できることが1つあります。 それでも、すべての文字列をapp.getpath引数に渡すことができます。 そこにもParを反映できたらいいのではないでしょうか。

我々はできる! バージョン4.1のリリースでは、TypeScriptはテンプレートリテラルタイプを作成できるようになりました。 構文的には、文字列テンプレートリテラルと同じように機能しますが、型レベルで機能します。 セットstring文字列リテラル型のサブセットに分割できた場合(メソッドの場合と同様)、テンプレートリテラル型を使用すると、文字列のスペクトル全体を含めることができます。

IncludesRouteParamsというタイプを作成しましょう。ここで、パラメーター名の前にコロンを追加するExpressスタイルの方法にParが適切に含まれていることを確認します。

 type IncludesRouteParams<Par extends string> = | `${string}/:${Par}` | `${string}/:${Par}/${string}`;

ジェネリック型IncludesRouteParamsは、 stringのサブセットである1つの引数を取ります。 これは、2つのテンプレートリテラルの共用体タイプを作成します。

  1. 最初のテンプレートリテラルは任意のstringで始まり、 /文字、 :文字、パラメータ名が続きます。 これにより、パラメータがルート文字列の最後にあるすべてのケースを確実にキャッチできます。
  2. 2番目のテンプレートリテラルは任意のstringで始まり、その後に同じパターンの/ :およびパラメータ名が続きます。 次に、別の/文字があり、その後に任意の文字列が続きます。 共用体タイプのこのブランチは、パラメーターがルート内のどこかにあるすべてのケースを確実にキャッチします。

これは、パラメーター名userIDを持つIncludesRouteParamsがさまざまなテストケースでどのように動作するかを示しています。

 const a: IncludeRouteParams<"userID"> = "/api/user/:userID" // const a: IncludeRouteParams<"userID"> = "/api/user/:userID/orders" // const a: IncludeRouteParams<"userID"> = "/api/user/:userId" // const a: IncludeRouteParams<"userID"> = "/api/user" // const a: IncludeRouteParams<"userID"> = "/api/user/:userIDAndmore" //

get関数宣言に新しいユーティリティタイプを含めましょう。

 function get<Par extends string = string>( path: IncludesRouteParams<Par>, callback: CallbackFn<"GET", Par> ) { // to be implemented } app.get<"userID">( "/api/users/:userID", function (req, res) { req.params.userID; // YEAH! } );

すごい! 実際のルートにパラメータを追加することを見逃さないようにするために、別の安全メカニズムを取得します。 なんてパワフル。

一般的なバインディング

しかし、何を推測しますか、私はまだそれに満足していません。 そのアプローチにはいくつかの問題があり、ルートがもう少し複雑になるとすぐに明らかになります。

  1. 私が抱えている最初の問題は、ジェネリック型パラメーターでパラメーターを明示的に記述する必要があるということです。 とにかく関数のpath引数で指定する場合でも、 Par"userID"にバインドする必要があります。 これはJavaScript-yではありません!
  2. このアプローチは、1つのルートパラメータのみを処理します。 和集合を追加した瞬間、たとえば"userID" | "orderId" "userID" | "orderId"フェイルセーフチェックは、これらの引数の1つだけが使用可能であるという条件で満たされます。 これがセットの仕組みです。 どちらか一方になります。

より良い方法があるに違いありません。 そこには。 そうでなければ、この記事は非常に苦いメモで終わります。

順番を逆にしましょう! ジェネリック型変数でルートパラメータを定義するのではなく、 app.getの最初の引数として渡すpathから変数を抽出してみましょう。

実際の値を取得するには、TypeScriptで汎用バインディングがどのように機能するかを確認する必要があります。 このidentity関数を例にとってみましょう。

 function identity<T>(inp: T) : T { return inp }

これは今まで見た中で最も退屈なジェネリック関数かもしれませんが、1つのポイントを完全に示しています。 identityは1つの引数を取り、同じ入力を再び返します。 型はジェネリック型Tであり、同じ型を返します。

これで、 Tstringにバインドできます。次に例を示します。

 const z = identity<string>("yes"); // z is of type string

この明示的に汎用的なバインディングにより、 stringsのみがidentityに渡されます。明示的にバインドされるため、戻り型もstringになります。 バインドするのを忘れると、何か面白いことが起こります。

 const y = identity("yes") // y is of type "yes"

その場合、TypeScriptは渡した引数から型を推測し、 T文字列リテラル型"yes"にバインドします。 これは、関数の引数をリテラル型に変換する優れた方法であり、他のジェネリック型で使用します。

app.getを適応させてそれをやってみましょう。

 function get<Path extends string = string>( path: Path, callback: CallbackFn<"GET", ParseRouteParams<Path>> ) { // to be implemented }

Parジェネリック型を削除し、 Pathを追加します。 Pathは、任意のstringのサブセットにすることができます。 pathをこのジェネリック型Pathに設定します。これは、パラメータを渡してgetする瞬間に、その文字列リテラル型をキャッチすることを意味します。 まだ作成していない新しいジェネリック型ParseRouteParamsPathを渡します。

ParseRouteParamsに取り組みましょう。 ここでは、イベントの順序を再度切り替えます。 パスが正常であることを確認するために要求されたルートパラメータをジェネリックに渡す代わりに、ルートパスを渡し、可能なルートパラメータを抽出します。 そのためには、条件付きタイプを作成する必要があります。

条件付きタイプと再帰的テンプレートリテラルタイプ

条件付き型は、構文的にはJavaScriptの三項演算子に似ています。 条件を確認し、条件が満たされている場合はブランチAを返し、そうでない場合はブランチBを返します。次に例を示します。

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}` ? P : never;

ここでは、 Rteが、Expressスタイルの最後のパラメーター(前に"/:"が付いている)で終わるすべてのパスのサブセットであるかどうかを確認します。 もしそうなら、この文字列を推測します。 つまり、その内容を新しい変数にキャプチャします。 条件が満たされた場合は、新しく抽出された文字列を返します。それ以外の場合は、「ルートパラメータがありません」のように、決して返しません。

試してみると、次のようなものが得られます。

 type Params = ParseRouteParams<"/api/user/:userID"> // Params is "userID" type NoParams = ParseRouteParams<"/api/user"> // NoParams is never --> no params!

素晴らしいです、それは私たちが以前にしたよりもすでにはるかに優れています。 ここで、他のすべての可能なパラメーターをキャッチします。 そのために、別の条件を追加する必要があります。

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}/${infer Rest}` ? P | ParseRouteParams<`/${Rest}`> : Rte extends `${string}/:${infer P}` ? P : never;

条件付きタイプは次のように機能します。

  1. 最初の条件では、ルート間のどこかにルートパラメータがあるかどうかを確認します。 その場合、ルートパラメータとその後に続くすべてのものの両方を抽出します。 新しく見つかったルートパラメータPを、 Restを使用して同じジェネリック型を再帰的に呼び出すユニオンで返します。 たとえば、ルート"/api/users/:userID/orders/:orderID"ParseRouteParamsに渡す場合、 "userID"Pに、 "orders/:orderID"Restに推測します。 同じタイプをRestと呼びます
  2. ここで2番目の条件が発生します。ここで、最後にタイプがあるかどうかを確認します。 これは"orders/:orderID"の場合です。 "orderID"を抽出し、このリテラル型を返します。
  3. ルートパラメータが残っていない場合は、決して戻りません。

Dan Vanderkamは、 ParseRouteParamsの同様の、より複雑なタイプを示していますが、上記のタイプも同様に機能するはずです。 新しく適応したParseRouteParamsを試してみると、次のようになります。

 // Params is "userID" type Params = ParseRouteParams<"/api/user/:userID"> // MoreParams is "userID" | "orderID" type MoreParams = ParseRouteParams<"/api/user/:userID/orders/:orderId">

この新しいタイプを適用して、 app.getの最終的な使用法がどのようになるかを見てみましょう。

 app.get("/api/users/:userID/orders/:orderID", function (req, res) { req.params.userID; // YES!! req.params.orderID; // Also YES!!! });

おお。 これは、最初に持っていたJavaScriptコードのように見えます。

動的動作の静的タイプ

1つの関数app.getに対して作成したタイプは、発生する可能性のある大量のエラーを除外するようにしてください。

  1. res.status()に渡すことができるのは適切な数値ステータスコードのみです。
  2. req.methodは4つの可能な文字列の1つであり、 app.getを使用すると、 "GET"のみであることがわかります。
  3. ルートパラメータを解析して、コールバック内にタイプミスがないことを確認できます

この記事の冒頭の例を見ると、次のエラーメッセージが表示されます。

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { // ^^^^^^^^^^^^^^^^^^^^^ // This condition will always return 'false' // since the types '"GET"' and '"POST"' have no overlap. res.status(20).send({ // ^^ // Argument of type '20' is not assignable to // parameter of type 'StatusCode' message: "Welcome, user " + req.params.userId // ^^^^^^ // Property 'userId' does not exist on type // '{ userID: string; }'. Did you mean 'userID'? }); } })

そして、実際にコードを実行する前に、これらすべてを実行してください。 Expressスタイルのサーバーは、JavaScriptの動的な性質の完璧な例です。 呼び出すメソッド、最初の引数に渡す文字列に応じて、コールバック内で多くの動作が変化します。 別の例を見てみると、すべてのタイプが完全に異なって見えます。

しかし、いくつかの明確に定義されたタイプを使用すると、コードの編集中にこの動的な動作をキャッチできます。 物事がブームになる実行時ではなく、静的型を使用したコンパイル時!

そしてこれがTypeScriptの力です。 私たちがよく知っているすべての動的JavaScriptの動作を形式化しようとする静的型システム。 作成したばかりの例を試してみたい場合は、TypeScriptプレイグラウンドにアクセスして、いじってみてください。


StefanBaumgartnerによる50レッスンのTypeScript この記事では、多くの概念に触れました。 詳細を知りたい場合は、50レッスンのTypeScriptをチェックしてください。ここでは、小さくて簡単に消化できるレッスンで型システムを穏やかに紹介しています。 電子ブックのバージョンはすぐに利用可能であり、印刷されたブックはコーディングライブラリの優れたリファレンスになります。