Динамическая статическая типизация в TypeScript

Опубликовано: 2022-03-10
Краткое резюме ↬ В этой статье мы рассмотрим некоторые из более продвинутых функций TypeScript, таких как типы объединения, условные типы, литеральные типы шаблонов и дженерики. Мы хотим формализовать наиболее динамичное поведение JavaScript таким образом, чтобы мы могли обнаруживать большинство ошибок до того, как они произойдут. Мы применяем несколько выводов из всех глав TypeScript в книге «50 уроков», которую мы опубликовали в журнале Smashing Magazine в конце 2020 года. Если вам интересно узнать больше, обязательно ознакомьтесь с ней!

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 должен быть сопоставлен с параметром 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 */ }); } })

Ух ты! Три строки кода реализации и три ошибки? Что произошло?

  1. Первая ошибка имеет нюанс. Хотя мы говорим нашему приложению, что хотим прослушивать запросы GET (отсюда app.get ), мы делаем что-то только в том случае, если метод запроса POST . В данный момент в нашем приложении req.method не может быть POST . Таким образом, мы никогда не будем отправлять ответ, который может привести к неожиданному тайм-ауту.
  2. Отлично, что мы явно отправляем код состояния! Однако 20 не является допустимым кодом состояния. Клиенты могут не понять, что здесь происходит.
  3. Это ответ, который мы хотим отправить обратно. Мы обращаемся к проанализированным аргументам, но имеем среднюю опечатку. Это userID , а не userId пользователя. Все наши пользователи будут приветствоваться словами «Добро пожаловать, пользователь не определен!». То, что вы определенно видели в дикой природе!

И такие вещи случаются! Особенно в JavaScript. Мы обретаем выразительность — нам ни разу не приходилось возиться с типами — но приходится уделять пристальное внимание тому, что мы делаем.

Это также то, где JavaScript вызывает много негативной реакции со стороны программистов, которые не привыкли к динамическим языкам программирования. У них обычно есть компиляторы, указывающие им на возможные проблемы и заранее отлавливающие ошибки. Они могут выглядеть высокомерными, когда осуждают объем дополнительной работы, которую вы должны проделать в своей голове, чтобы убедиться, что все работает правильно. Они могут даже сказать вам, что в JavaScript нет типов. Это неправда.

Андерс Хейлсберг, ведущий архитектор TypeScript, сказал в своем программном выступлении на MS Build 2017, что « дело не в том, что в JavaScript нет системы типов. Просто нет возможности это формализовать ».

И это основная цель TypeScript. TypeScript хочет понимать ваш код JavaScript лучше, чем вы. И там, где TypeScript не может понять, что вы имеете в виду, вы можете помочь, предоставив дополнительную информацию о типе.

Еще после прыжка! Продолжить чтение ниже ↓

Базовая типизация

И это то, что мы собираемся сделать прямо сейчас. Давайте возьмем метод get с нашего сервера в стиле Express и добавим достаточно информации о типе, чтобы мы могли исключить как можно больше категорий ошибок.

Начнем с некоторой базовой информации о типах. У нас есть объект app , который указывает на функцию get . Функция 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 включает в себя все возможные логические значения, которые являются true и false .

TypeScript позволяет уточнить эти наборы до меньших подмножеств. Например, мы можем создать Method типа, включающий все возможные строки, которые мы можем получить для методов HTTP:

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

Method — это меньший набор большего набора string . Method является типом объединения литеральных типов. Литеральный тип — это наименьшая единица данного набора. Буквальная строка. Буквальное число. Нет никакой двусмысленности. Это просто "GET" . Вы помещаете их в объединение с другими литеральными типами, создавая подмножество любых более крупных типов, которые у вас есть. Вы также можете создать подмножество с литеральными типами string и number или с различными типами составных объектов. Есть много возможностей комбинировать и объединять литеральные типы в союзы.

Это немедленно влияет на обратный вызов нашего сервера. Внезапно мы можем различать эти четыре метода (или больше, если необходимо) и исчерпать все возможности в коде. 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 в 50 уроках посвящены изучению всех тонкостей дженериков и их уникальной функциональности.

Что вам нужно знать прямо сейчас, так это то, что мы хотим определить ServerRequest таким образом, чтобы мы могли указать часть Methods вместо всего набора. Для этого мы используем общий синтаксис, в котором мы можем определять параметры, как и в случае с функциями:

 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 , такие как CallbackFn и функция get :

 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 });

Это гарантирует, что мы не предполагаем, что методы HTTP, такие как "POST" или подобные, доступны, когда мы создаем обратный вызов app.get . Мы точно знаем, с чем имеем дело на данный момент, так что давайте отразим это в наших типах.

Мы уже многое сделали, чтобы убедиться, что 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!! });

Замечательно! Однако есть одна мелочь, которую можно улучшить. Мы по-прежнему можем передавать каждую строку в аргумент path app.get . Не было бы лучше, если бы мы могли отразить и Par ?

Мы можем! С выпуском версии 4.1 TypeScript может создавать литеральные типы шаблонов . Синтаксически они работают так же, как строковые литералы шаблонов, но на уровне типов. Там, где мы могли разделить string набора на подмножества с типами строковых литералов (как мы сделали с методами), типы литералов шаблонов позволяют нам включать весь спектр строк.

Давайте создадим тип с IncludesRouteParams , в котором мы хотим убедиться, что Par правильно включен в способ добавления двоеточия в стиле Express перед именем параметра:

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

Универсальный IncludesRouteParams принимает один аргумент, который является подмножеством string . Он создает тип объединения двух литералов шаблона:

  1. Первый литерал шаблона начинается с любой string , затем включает символ / , за которым следует символ : , за которым следует имя параметра. Это гарантирует, что мы поймаем все случаи, когда параметр находится в конце строки маршрута.
  2. Второй литерал шаблона начинается с любой string , за которой следует тот же шаблон из / , : и имя параметра. Затем у нас есть еще один символ / , за которым следует любая строка. Эта ветвь типа объединения гарантирует, что мы поймаем все случаи, когда параметр находится где-то внутри маршрута.

Вот как IncludesRouteParams с именем параметра userID ведет себя с разными тестовыми примерами:

 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-у!
  2. Этот подход обрабатывает только один параметр маршрута. В тот момент, когда мы добавляем объединение, например, "userID" | "orderId" "userID" | "orderId" проверка отказоустойчивости выполняется только при наличии одного из этих аргументов. Так работают наборы. Это может быть и то, и другое.

Должен быть лучший способ. И есть. Иначе эта статья закончилась бы на очень горькой ноте.

Давайте обратим порядок! Давайте не будем пытаться определить параметры маршрута в переменной универсального типа, а вместо этого извлечем переменные из path , который мы передаем в качестве первого аргумента app.get .

Чтобы добраться до фактического значения, мы должны увидеть, как общая привязка работает в 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 и добавляем Path . Path может быть подмножеством любой 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. Если больше не осталось параметра маршрута, мы возвращаем никогда.

Дэн Вандеркам показывает похожий и более сложный тип для 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 и поиграйтесь с ним.


TypeScript в 50 уроках Стефана Баумгартнера В этой статье мы коснулись многих понятий. Если вы хотите узнать больше, ознакомьтесь с TypeScript в 50 уроках, где вы получите краткое введение в систему типов в небольших, легко усваиваемых уроках. Электронные версии доступны сразу, а печатная книга станет отличным справочником для вашей библиотеки кодирования.