TypeScript 中的動態靜態類型

已發表: 2022-03-10
快速總結↬在本文中,我們將了解 TypeScript 的一些更高級的特性,例如聯合類型、條件類型、模板文字類型和泛型。 我們希望以一種我們可以在大多數錯誤發生之前捕獲它們的方式來形式化最動態的 JavaScript 行為。 我們在 50 課中應用了從 TypeScript 所有章節中學到的一些知識,這本書我們已於 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 }); } })

我們有一個 https://expressjs.com/ 風格的服務器,它允許我們定義路由(或路徑),並在請求 URL 時執行回調。

回調有兩個參數:

  1. request對象。
    在這裡,我們獲得了有關使用的 HTTP 方法的信息(例如 GET、POST、PUT、DELETE)以及傳入的其他參數。在此示例中, userID應該映射到包含用戶 ID 的參數userID
  2. responsereply對象。
    在這裡,我們要準備從服務器到客戶端的正確響應。 我們希望發送正確的狀態代碼(方法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 */ }); } })

哦哇! 三行實現代碼,三個錯誤? 發生了什麼?

  1. 第一個錯誤是細微的。 當我們告訴我們的應用我們想要監聽GET請求(因此app.get )時,我們只有在請求方法是POST時才做一些事情。 在我們的應用程序的這個特定點上, req.method不能是POST 。 所以我們永遠不會發送任何響應,這可能會導致意外超時。
  2. 太好了,我們明確發送了狀態碼! 但是, 20不是有效的狀態碼。 客戶可能不明白這裡發生了什麼。
  3. 這是我們要發回的響應。 我們訪問解析的參數,但有一個平均錯字。 這是userID不是userId 。 我們所有的用戶都會收到“歡迎,用戶未定義!”的問候。 你肯定在野外見過的東西!

像這樣的事情發生了! 尤其是在 JavaScript 中。 我們獲得了表現力——我們不必為類型而煩惱——而是必須密切關注我們正在做的事情。

這也是 JavaScript 受到不習慣動態編程語言的程序員強烈反對的地方。 他們通常讓編譯器指出可能的問題並提前發現錯誤。 當他們對您必須在腦海中做的額外工作量以確保一切正常時皺眉頭時,他們可能會變得傲慢。 他們甚至可能會告訴你 JavaScript 沒有類型。 這不是真的。

TypeScript 的首席架構師 Anders Hejlsberg 在他的 MS Build 2017 主題演講中說:“並不是說 JavaScript 沒有類型系統。 只是沒有辦法將其正式化”。

這就是 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是一個接受兩個參數的函數類型:

  • req ,其類型為ServerRequest
  • reply類型為ServerReply

CallbackFn返回void

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

ServerRequest在大多數框架中是一個相當複雜的對象。 我們為演示目的做了一個簡化版本。 我們傳入一個method字符串,用於"GET""POST""PUT""DELETE"等。它還有一個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包括所有可能的具有雙浮點精度的數字。 boolean包括所有可能的布爾值,它們是truefalse

TypeScript 允許您將這些集合細化為更小的子集。 例如,我們可以創建一個Method類型,其中包含我們可以為 HTTP 方法接收的所有可能的字符串:

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

Method是較大string集合中的較小集合。 Method是文字類型的聯合類型。 文字類型是給定集合的最小單位。 一個文字字符串。 一個字面數字。 沒有歧義。 這只是"GET" 。 您將它們與其他文字類型放在一個聯合中,創建您擁有的任何更大類型的子集。 您還可以使用stringnumber的文字類型或不同的複合對像類型來做一個子集。 有很多可能性可以將文字類型組合併放入聯合中。

這對我們的服務器回調有直接影響。 突然之間,我們可以區分這四種方法(或者更多,如果需要的話),並且可以用盡代碼中的所有可能性。 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; } });

對於您所做的每一個case陳述,TypeScript 都可以為您提供有關可用選項的信息。 自己試試吧。 如果你用盡了所有選項,TypeScript 會在你的default分支中告訴你這never發生。 這實際上是never類型,這意味著您可能已達到需要處理的錯誤狀態。

那是一類錯誤少。 我們現在確切地知道哪些可能的 HTTP 方法可用。

我們可以對 HTTP 狀態碼做同樣的事情,通過定義statusCode可以採用的有效數字的子集:

 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" 。 但是使用我們的類型定義,我們仍然必須檢查聯合的所有可能部分。

CallbackFn的類型是正確的,因為我們可以為所有可能的 HTTP 方法定義回調函數,但是如果我們顯式調用app.get ,最好節省一些額外的步驟,這些步驟只需要符合類型。

TypeScript 泛型可以提供幫助! 泛型是 TypeScript 的主要功能之一,它允許您從靜態類型中獲得最動態的行為。 在 TypeScript in 50 Lessons 中,我們在最後三章中深入研究了泛型的所有復雜性及其獨特的功能。

您現在需要知道的是,我們希望以一種可以指定Methods的一部分而不是整個集合的方式定義ServerRequest 。 為此,我們使用通用語法來定義參數,就像我們對函數所做的那樣:

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

這就是發生的事情:

  1. ServerRequest成為泛型類型,如尖括號所示
  2. 我們定義了一個名為Met的泛型參數,它是Methods類型的子集
  3. 我們使用這個泛型參數作為泛型變量來定義方法。

我還鼓勵您查看我關於命名泛型參數的文章。

通過該更改,我們可以指定不同的ServerRequest ,而無需重複:

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

由於我們更改了ServerRequest的接口,我們必須對使用ServerRequest的所有其他類型進行更改,例如CallbackFnget函數:

 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聯合類型進行子集化的一個好處是,我們可以在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鍵的記錄。 我們現在的任務是讓它更具體一點!

我們通過添加另一個通用變量來做到這一點。 一個用於方法,一個用於我們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!! });

那太棒了! 不過,有一點可以改進。 我們仍然可以將每個字符串傳遞給app.getpath參數。 如果我們也能在其中反映Par不是更好嗎?

我們可以! 隨著 4.1 版的發布,TypeScript 能夠創建模板文字類型。 從語法上講,它們就像字符串模板文字一樣工作,但在類型級別上。 我們能夠將集合string拆分為具有字符串文字類型的子集(就像我們對方法所做的那樣),模板文字類型允許我們包含整個字符串範圍。

讓我們創建一個名為IncludesRouteParams的類型,我們要確保Par正確地包含在 Express 樣式的參數名稱前添加冒號的方式中:

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

泛型類型IncludesRouteParams採用一個參數,它是string的子集。 它創建兩個模板文字的聯合類型:

  1. 第一個模板文字以任何string開頭,然後包括一個/字符,後跟一個:字符,然後是參數名稱。 這可以確保我們捕獲參數位於路由字符串末尾的所有情況。
  2. 第二個模板文字以任何string開頭,後跟/:和參數名稱的相同模式。 然後我們有另一個/字符,後跟任何字符串。 聯合類型的這個分支確保我們捕獲參數位於路由中某處的所有情況。

這是具有參數名稱userIDIncludesRouteParams在不同測試用例中的行為方式:

 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. 我遇到的第一個問題是我們需要在泛型類型參數中顯式聲明我們的參數。 我們必須將Par綁定到"userID" ,即使我們會在函數的路徑參數中指定它。 這不是 JavaScript-y!
  2. 這種方法只處理一個路由參數。 我們添加聯合的那一刻,例如"userID" | "orderId" "userID" | "orderId"故障安全檢查僅滿足這些參數之一可用。 這就是集合的工作方式。 它可以是一個,也可以是另一個。

一定會有更好的辦法。 有。 否則,這篇文章將以非常痛苦的方式結束。

讓我們顛倒順序! 讓我們不要嘗試在泛型類型變量中定義路由參數,而是從我們作為app.get的第一個參數傳遞的path中提取變量。

要獲得實際值,我們必須了解泛型綁定在 TypeScript 中是如何工作的。 讓我們以這個identity函數為例:

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

它可能是你見過的最無聊的通用函數,但它完美地說明了一點。 identity接受一個參數,並再次返回相同的輸入。 該類型是泛型類型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泛型類型並添加PathPath可以是任何string的子集。 我們將path設置為這個泛型類型Path ,這意味著當我們將參數傳遞給get時,我們會捕獲它的字符串字面量類型。 我們將Path傳遞給尚未創建的新泛型類型ParseRouteParams

讓我們處理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. 這就是第二個條件出現的地方。這裡我們檢查最後是否有類型。 "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 代碼!

動態行為的靜態類型

我們剛剛為一個函數app.get創建的類型確保我們排除了大量可能的錯誤:

  1. 我們只能將正確的數字狀態代碼傳遞給res.status()
  2. req.method是四個可能的字符串之一,當我們使用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 遊樂場並擺弄它。


Stefan Baumgartner 的 TypeScript 50 課 在本文中,我們涉及了許多概念。 如果您想了解更多信息,請查看 TypeScript in 50 Lessons,在這裡您可以通過簡單易懂的小型課程對類型系統進行溫和的介紹。 電子書版本立即可用,印刷書將為您的編碼庫提供很好的參考。