Расширенный TypeScript с реальным примером (часть 2)

Опубликовано: 2020-11-20

Пришло время продолжить (и, надеюсь, закончить) наш учебник по TypeScript. Если вы пропустили предыдущие посты, которые мы писали о TypeScript, вот они: наше первоначальное введение в TypeScript и первая часть этого руководства, где я объясняю пример JavaScript, с которым мы работаем, и шаги, которые мы предприняли для его частичного улучшения. .

Сегодня мы собираемся закончить наш пример, дополнив все, чего еще не хватает. В частности, сначала мы увидим, как создавать типы, являющиеся частичными версиями других существующих типов. Затем мы увидим, как правильно типизировать действия хранилища Redux, используя объединения типов, и обсудим преимущества, которые предлагают объединения типов. И, наконец, я покажу вам, как создать полиморфную функцию, тип возвращаемого значения которой зависит от ее аргументов.

Краткий обзор того, что мы уже сделали…

В первой части руководства мы использовали (часть) хранилище Redux, которое мы взяли из Nelio Content в качестве нашего рабочего примера. Все началось с простого кода JavaScript, который нужно было улучшить, добавив конкретные типы, которые сделали его более надежным и понятным. Так, например, мы определили следующие типы:

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; };

что помогло нам с первого взгляда понять, с какой информацией работает наш магазин. В этом конкретном случае, например, мы можем видеть, что состояние нашего приложения хранит две вещи: список posts (которые мы проиндексировали через их PostId ) и структуру, называемую days , которая, учитывая определенный день, возвращает список идентификаторы постов. Мы также можем увидеть атрибуты (и их конкретные типы), которые мы найдем в объекте Post .

Как только эти типы были определены, мы отредактировали все функции нашего примера, чтобы использовать их. Эта простая задача изменила непрозрачные сигнатуры функций JavaScript:

 // Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }

к понятным сигнатурам функций TypeScript:

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... }

Функция getPostsInDay — очень хороший пример того, насколько TypeScript улучшит качество вашего кода. Если вы посмотрите на аналог JavaScript, вы действительно не знаете, что вернет эта функция. Конечно, ее имя может намекать на тип результата (может быть, это список сообщений?), но вы должны посмотреть исходный код функции (и, возможно, действия и редьюсеры), чтобы быть уверенным (на самом деле это список сообщений). идентификаторы постов). Можно улучшить эту ситуацию, назвав вещи лучше (например, getIdsOfPostsInDay ), но нет ничего лучше конкретных типов, чтобы убрать любые сомнения: PostId[] .

Итак, теперь, когда вы в курсе текущего положения дел, пришло время двигаться дальше и исправить все, что мы пропустили на прошлой неделе. В частности, мы знаем, что нам нужно ввести атрибуты attributes функции updatePost , и нам нужно определить типы, которые будут иметь наши действия (обратите внимание, что в reducer атрибут action прямо сейчас имеет тип any ).

Как ввести объект, атрибуты которого являются подмножеством атрибутов другого объекта

Давайте разогреемся, начав с чего-нибудь простого. Функция updatePost генерирует действие, сигнализирующее о нашем намерении обновить определенные атрибуты данного идентификатора сообщения. Вот как это выглядит:

 function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }

и вот как действие используется редюсером для обновления поста в нашем магазине:

 function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }

Как видите, редьюсер ищет запись в магазине и, если она там есть, обновляет свои атрибуты, перезаписывая их теми, которые включены в действие.

Но что такое attributes действия? Что ж, они явно чем-то похожи на Post , так как они должны перезаписывать атрибуты, которые мы можем найти в сообщении:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };

но если мы попытаемся использовать это, мы увидим, что это не работает:

 const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, };

потому что мы не хотим, чтобы attributes были самой Post ; мы хотим, чтобы это было подмножество атрибутов Post (т.е. мы хотим указать только те атрибуты объекта Post , которые мы будем перезаписывать).

Чтобы решить эту проблему, просто используйте тип Partial утилиты:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };

Вот и все! Или это?

Явная фильтрация атрибутов

Предыдущий фрагмент по-прежнему ошибочен, так как можно получить некоторые ошибки времени выполнения, которые компилятор TypeScript не проверяет. И вот почему: действие, сигнализирующее об обновлении сообщения, имеет два аргумента: идентификатор сообщения и набор атрибутов, которые мы хотим обновить. Как только у нас есть готовое действие, редуктор отвечает за перезапись существующего сообщения новыми значениями:

 const post = { ...state.posts[ action.postId ], ...action.attributes, };

И это именно ошибочная часть нашего кода; возможно, атрибут postId действия имеет идентификатор горшка x , а атрибут id в attributes имеет другой идентификатор сообщения y :

 const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, };

Очевидно, это допустимое действие, поэтому TypeScript не вызывает никаких ошибок, но мы знаем, что этого не должно быть. Атрибут id в attributes (если есть) и атрибут postId должны иметь одинаковое значение, иначе у нас несогласованное действие. Наш тип действия неточен, потому что он позволяет нам определить ситуацию, которая должна быть невозможной… так как же мы можем это исправить? Очень просто: просто измените этот тип так, чтобы этот сценарий, который должен быть невозможным, стал фактически невозможным.

Первое решение, о котором я подумал, заключается в следующем: удалить атрибут postId из действия и добавить идентификатор в attributes атрибутов:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; }

Затем обновите редюсер, чтобы он использовал action.attributes.id вместо action.postId для поиска и перезаписи существующего сообщения.

К сожалению, это решение не идеально, потому что attributes — это «частичный пост», помните? Это означает, что теоретически атрибут id может быть, а может и не быть в объекте attributes . Конечно, мы знаем, что оно там будет, потому что именно мы генерируем действие… но наши типы все еще неточны. Если в будущем кто-то изменит функцию updatePost и не проверит, что attributes включают postId , результирующее действие будет допустимо в соответствии с TypeScript, но наш код не будет работать:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Итак, если мы хотим, чтобы TypeScript нас защищал, мы должны быть максимально точными при указании типов и следить за тем, чтобы они делали невозможные состояния невозможными. Учитывая все это, у нас есть только два варианта:

  1. Если у нас есть атрибут postId в действии (как мы делали в начале), то объект attributes не должен содержать атрибут id .
  2. Если, с другой стороны, у действия нет атрибута postId , тогда attributes должны содержать атрибут id .

Первое решение можно легко указать с помощью другого служебного типа, Omit , который позволяет нам создать новый тип, удалив атрибуты из существующего типа:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };

который работает, как ожидалось:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, };

Для второго варианта мы должны явно добавить атрибут id поверх определенного нами типа Partial<Post> :

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };

что, опять же, дает нам ожидаемый результат:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Типы союзов

В предыдущем разделе мы уже видели, как ввести одно из двух действий, которые есть в нашем магазине. Проделаем то же самое со вторым действием. Зная, что receiveNewPost выглядит так:

 function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }

его тип можно определить следующим образом:

 type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };

Легко, верно?

Теперь давайте взглянем на наш редюсер: он принимает state и action (тип которого мы пока не знаем) и создает новое State :

 function reducer( state: State, action: any ): State { ... }

В нашем магазине есть два разных типа действий: UpdatePostAction и ReceiveNewPostAction . Итак, каков тип аргумента action ? То или другое, верно? Когда переменная может принимать более одного типа A , B , C и т. д., ее тип является объединением типов. То есть его тип может быть либо A , либо B , либо C и так далее. Типы объединения — это тип, значения которого могут относиться к любому из типов, указанных в этом объединении.

Вот как наш тип Action может быть определен как тип union:

 type Action = UpdatePostAction | ReceiveNewPostAction;

В предыдущем фрагменте просто говорится, что Action может быть либо экземпляром типа UpdatePostAction , либо экземпляром типа ReceiveNewPostAction .

Если мы теперь используем Action в нашем редюсере:

 function reducer( state: State, action: Action ): State { ... }

мы можем видеть, как эта новая версия нашего кода, которая хорошо типизирована, работает гладко.

Как типы Union устраняют случаи по умолчанию

«Секундочку, — скажете вы, — предыдущая ссылка работает некорректно, компилятор выдает ошибку!» Действительно, согласно TypeScript, наш редьюсер содержит недостижимый код:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }

Чего ждать? Позвольте мне объяснить, что здесь происходит…

Созданный нами тип объединения Action на самом деле является размеченным типом объединения. Размеченный тип объединения — это тип объединения, в котором все его типы имеют общий атрибут, значение которого можно использовать для отличия одного типа от другого.

В нашем случае два типа Action имеют атрибут type со значениями RECEIVE_NEW_POST для ReceiveNewPostAction и UPDATE_POST для UpdatePostAction . Поскольку мы знаем, что Action обязательно является экземпляром либо одного действия, либо другого, две ветви нашего switch охватывают все возможности: либо action.type — это RECEIVE_NEW_POST , либо это UPDATE_POST . Следовательно, окончательный return является избыточным и будет недостижим.

Предположим, что мы удаляем этот return , чтобы исправить эту ошибку. Получили ли мы что-то кроме удаления ненужного кода? Ответ положительный. Если мы теперь добавим новый тип действия в наш код:

 type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... };

вдруг оператор switch в нашем редюсере перестанет охватывать все возможные сценарии:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit }

Это означает, что редьюсер может неявно вернуть undefined значение, если мы вызовем его с помощью действия типа NEW_FEATURE , а это не соответствует сигнатуре функции. Из-за этого несоответствия TypeScript жалуется и сообщает нам, что нам не хватает новой ветки для работы с этим новым типом действия.

Полиморфные функции с переменными возвращаемыми типами

Если вы зашли так далеко, поздравляем: вы узнали все, что нужно сделать, чтобы улучшить исходный код ваших приложений JavaScript с помощью TypeScript. И, в качестве награды, я поделюсь с вами «проблемой», с которой столкнулся несколько дней назад, и ее решением. Почему? Потому что TypeScript — сложный и увлекательный мир, и я хочу показать вам, насколько это верно.

В начале всего этого приключения мы видели, что один из имеющихся у нас селекторов — это getPostsInDay , и как его возвращаемый тип представляет собой список идентификаторов сообщений:

 function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }

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

 const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );

Можем ли мы сделать это в TypeScript? Конечно, мы делаем! Иначе зачем бы я поднимал этот вопрос? Все, что нам нужно сделать, это определить полиморфную функцию, результат которой зависит от входных параметров.

Итак, идея в том, что нам нужны две разные версии одной и той же функции. Следует вернуть список PostId , если один из атрибутов является идентификатором string . Другой должен вернуть список Post , если тот же самый атрибут является string all .

Давайте создадим их обоих:

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }

Легко, верно? НЕПРАВИЛЬНО! Это не работает. Согласно TypeScript, у нас есть «двойная реализация функции».

Ладно, тогда попробуем что-нибудь другое. Давайте объединим два предыдущих определения в одну функцию:

 function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }

Это ведет себя так, как мы хотим? Боюсь, что нет…

Вот что говорит нам эта сигнатура функции: « getPostsInDay — это функция, которая принимает два аргумента, state и mode , значения которых могут быть либо id , либо all ; его возвращаемый тип будет либо списком PostId , либо списком Post s». Другими словами, предыдущее определение функции нигде не указывает, что существует связь между значением, переданным аргументу mode , и типом возвращаемого значения функции. И так такой код:

 const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

действителен и не ведет себя так, как мы этого хотим.

Хорошо, последняя попытка. Что, если мы смешаем нашу первоначальную интуицию, когда мы описываем конкретные сигнатуры функций, с единственной действительной реализацией?

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); }

Предыдущий фрагмент имеет действительную реализацию функции, которая работает, но определяет две дополнительные сигнатуры функции, которые связывают конкретные значения в mode с типом возвращаемого значения функции.

Используя этот подход, этот код действителен:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

а этот нет:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );

Выводы

В этой серии статей мы увидели, что такое TypeScript и как мы можем применять его в наших проектах. Типы помогают нам лучше документировать код, предоставляя семантический контекст. Кроме того, типы также добавляют дополнительный уровень безопасности, поскольку компилятор TypeScript заботится о том, чтобы наш код правильно сочетался друг с другом, как это делает Lego.

На данный момент у вас уже есть все необходимые инструменты, чтобы поднять качество вашей работы на новый уровень. Удачи в этом новом приключении!

Избранное изображение Майка Кеннелли на Unsplash.