TypeScriptでの動的静的入力
公開: 2022-03-10JavaScriptは本質的に動的プログラミング言語です。 私たち開発者は、わずかな労力で多くのことを表現でき、言語とそのランタイムは、私たちが何をしようとしていたかを理解します。 これが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つの引数を取ります。
-
request
オブジェクト。
ここでは、使用されているHTTPメソッド(GET、POST、PUT、DELETEなど)と、入力される追加のパラメーターに関する情報を取得します。この例では、userID
は、ユーザーのIDを含むパラメーターuserID
にマップする必要があります。 -
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つのエラー? 何が起きたの?
- 最初のエラーは微妙です。 GETリクエスト(したがって
app.get
)をリッスンすることをアプリに通知しますが、リクエストメソッドがPOSTの場合にのみ何かを実行します。 アプリケーションのこの特定の時点では、req.method
をPOSTにすることはできません。 そのため、予期しないタイムアウトが発生する可能性のある応答を送信することはありません。 - ステータスコードを明示的に送信するのは素晴らしいことです。 ただし、
20
は有効なステータスコードではありません。 クライアントはここで何が起こっているのか理解できないかもしれません。 - これは私たちが送り返したい応答です。 解析された引数にアクセスしますが、平均的なタイプミスがあります。
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
です - タイプ
ServerReply
のreply
CallbackFn
はvoid
返します。
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
には、 true
とfalse
のすべての可能なブール値が含まれます。
TypeScriptを使用すると、これらのセットをより小さなサブセットに絞り込むことができます。 たとえば、HTTPメソッドで受け取ることができるすべての可能な文字列を含むタイプMethod
を作成できます。
type Methods= "GET" | "POST" | "PUT" | "DELETE"; type ServerRequest = { method: Methods; params: Record<string, string>; };
Method
は、大きなstring
セットの小さなセットです。 Method
は、リテラル型の共用体型です。 リテラル型は、特定のセットの最小単位です。 リテラル文字列。 リテラル数。 あいまいさはありません。 ただの"GET"
です。 それらを他のリテラル型と結合して、より大きな型のサブセットを作成します。 string
とnumber
の両方のリテラル型、または異なる複合オブジェクト型を使用してサブセットを作成することもできます。 リテラル型を組み合わせて結合する可能性はたくさんあります。
これは、サーバーのコールバックにすぐに影響します。 突然、これら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>; };
これが起こることです:
-
ServerRequest
は、山括弧で示されているように、ジェネリック型になります Methods
タイプのサブセットであるMet
と呼ばれるジェネリックパラメーターを定義します- このジェネリックパラメーターをジェネリック変数として使用して、メソッドを定義します。
また、ジェネリックパラメーターの命名に関する私の記事を確認することをお勧めします。
この変更により、重複することなく、さまざまなServerRequest
を指定できます。
type OnlyGET = ServerRequest<"GET">; type OnlyPOST = ServerRequest<"POST">; type POSTorPUT = ServerRquest<"POST" | "PUT">;
ServerRequest
のインターフェイスを変更したため、 CallbackFn
やget
関数など、 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.get
のpath
引数に渡すことができます。 そこにもPar
を反映できたらいいのではないでしょうか。
我々はできる! バージョン4.1のリリースでは、TypeScriptはテンプレートリテラルタイプを作成できるようになりました。 構文的には、文字列テンプレートリテラルと同じように機能しますが、型レベルで機能します。 セットstring
を文字列リテラル型のサブセットに分割できた場合(メソッドの場合と同様)、テンプレートリテラル型を使用すると、文字列のスペクトル全体を含めることができます。
IncludesRouteParams
というタイプを作成しましょう。ここで、パラメーター名の前にコロンを追加するExpressスタイルの方法にPar
が適切に含まれていることを確認します。
type IncludesRouteParams<Par extends string> = | `${string}/:${Par}` | `${string}/:${Par}/${string}`;
ジェネリック型IncludesRouteParams
は、 string
のサブセットである1つの引数を取ります。 これは、2つのテンプレートリテラルの共用体タイプを作成します。
- 最初のテンプレートリテラルは任意の
string
で始まり、/
文字、:
文字、パラメータ名が続きます。 これにより、パラメータがルート文字列の最後にあるすべてのケースを確実にキャッチできます。 - 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! } );
すごい! 実際のルートにパラメータを追加することを見逃さないようにするために、別の安全メカニズムを取得します。 なんてパワフル。
一般的なバインディング
しかし、何を推測しますか、私はまだそれに満足していません。 そのアプローチにはいくつかの問題があり、ルートがもう少し複雑になるとすぐに明らかになります。
- 私が抱えている最初の問題は、ジェネリック型パラメーターでパラメーターを明示的に記述する必要があるということです。 とにかく関数のpath引数で指定する場合でも、
Par
を"userID"
にバインドする必要があります。 これはJavaScript-yではありません! - このアプローチは、1つのルートパラメータのみを処理します。 和集合を追加した瞬間、たとえば
"userID" | "orderId"
"userID" | "orderId"
フェイルセーフチェックは、これらの引数の1つだけが使用可能であるという条件で満たされます。 これがセットの仕組みです。 どちらか一方になります。
より良い方法があるに違いありません。 そこには。 そうでなければ、この記事は非常に苦いメモで終わります。
順番を逆にしましょう! ジェネリック型変数でルートパラメータを定義するのではなく、 app.get
の最初の引数として渡すpath
から変数を抽出してみましょう。
実際の値を取得するには、TypeScriptで汎用バインディングがどのように機能するかを確認する必要があります。 このidentity
関数を例にとってみましょう。
function identity<T>(inp: T) : T { return inp }
これは今まで見た中で最も退屈なジェネリック関数かもしれませんが、1つのポイントを完全に示しています。 identity
は1つの引数を取り、同じ入力を再び返します。 型はジェネリック型T
であり、同じ型を返します。
これで、 T
をstring
にバインドできます。次に例を示します。
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
する瞬間に、その文字列リテラル型をキャッチすることを意味します。 まだ作成していない新しいジェネリック型ParseRouteParams
にPath
を渡します。
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;
条件付きタイプは次のように機能します。
- 最初の条件では、ルート間のどこかにルートパラメータがあるかどうかを確認します。 その場合、ルートパラメータとその後に続くすべてのものの両方を抽出します。 新しく見つかったルートパラメータ
P
を、Rest
を使用して同じジェネリック型を再帰的に呼び出すユニオンで返します。 たとえば、ルート"/api/users/:userID/orders/:orderID"
をParseRouteParams
に渡す場合、"userID"
をP
に、"orders/:orderID"
をRest
に推測します。 同じタイプをRest
と呼びます - ここで2番目の条件が発生します。ここで、最後にタイプがあるかどうかを確認します。 これは
"orders/:orderID"
の場合です。"orderID"
を抽出し、このリテラル型を返します。 - ルートパラメータが残っていない場合は、決して戻りません。
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
に対して作成したタイプは、発生する可能性のある大量のエラーを除外するようにしてください。
-
res.status()
に渡すことができるのは適切な数値ステータスコードのみです。 -
req.method
は4つの可能な文字列の1つであり、app.get
を使用すると、"GET"
のみであることがわかります。 - ルートパラメータを解析して、コールバック内にタイプミスがないことを確認できます
この記事の冒頭の例を見ると、次のエラーメッセージが表示されます。
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プレイグラウンドにアクセスして、いじってみてください。
この記事では、多くの概念に触れました。 詳細を知りたい場合は、50レッスンのTypeScriptをチェックしてください。ここでは、小さくて簡単に消化できるレッスンで型システムを穏やかに紹介しています。 電子ブックのバージョンはすぐに利用可能であり、印刷されたブックはコーディングライブラリの優れたリファレンスになります。