TypeScript — это не только про аннотацию типов. Примеры из практики

Kate

Administrator
Команда форума
Казалось бы, в 2021 году говорить об этом несколько поздновато — технология уже известна и популярна. Однако несмотря на это, много где он используется вполсилы. Простейшая аннотация типов, проверка на null и всё. Поэтому я принес несколько интересных, сугубо практических кейсов, чтобы показать, что TS может куда больше.

Кратенько о себе: с TS работаю больше 5 лет, любил, страдал (до ненависти дело так и не дошло, но бывало матерно) и вот сейчас на стадии приятелей. Если готовы — берите кофе, впереди будет мало текста и много кода. Мы поговорим о подмножествах, проекциях и динамическом выведении типов.

Сужение примитивов​

Однажды, прогуливаясь по морскому порту, Ученик спросил у Мастера: — Зачем нужен TypeScript, если у нас есть здравый смысл? В ответ Мастер указал на только что пришвартовавшихся иностранных моряков, и попросил Ученика поприветствовать их. Ученик с достоинством поклонился в сторону новоприбывших. Оскорбленные моряки набросились на Ученика и избили его.

Давайте начнем с простого. Представьте себе, что у нас есть некая функция, которая принимает на вход ключ, достает из словаря по ключу перевод и возвращает его. Какой будет тип этого ключа? Скорее всего — string. Но понятно, что не каждый string является допустимым ключом — нам точно не нужна вся британская энциклопедия, да и опечатки никто не отменял.

Решить эту проблему можно по-разному. Например, если перевода не существует, можно бросить исключение (пользователи будут рады). Можно исключение не бросать, вернуть тот же ключ, а факт отсутствия ключа залогировать, чтобы потом выяснить, что пошло не так. В любом случае, о том, что случилась проблема, мы узнаем постфактум, а хотелось бы заранее.

И тут у TypeScript есть очень элегантное решение — если тебе не нужен весь string, то скажи об этом честно и просто объяви подмножество:

type TranslationKey = 'grey';
function translate(key: TranslationKey){...}

Благодаря этому приему мы получаем сразу несколько преимуществ:

  • Что попало в метод, уже не передать — мы защищены от опечаток и ключей, к которым еще не готовы на этапе билда.
  • Когда какой-то ключ поменяется (grey -> gray), мы сразу увидим все места, где нужно обновить код.
  • Работает IntelliSense — использовать ключи стало намного проще, не надо каждый раз лезть и искать, как же этот landing_big_ad_imageblock_subheder пишется (кстати, вы заметили опечатку?).
Этот же прием можно использовать, если мы хотим провалидировать некое значение перед использованием:

type Email = string;
const sendEmail = (email: Email)=> {...}
sendEmail('fake@email'); // fails

Когда мы попытаемся передать в метод sendEmail любую строку, TS заставит нас ее проверить, например, написав guard:

// don't use in production please
const isValidEmail = (maybeEmail: unknown): maybeEmail is Email =>
typeof maybeEmail === "string" && /^\S+@\S+$/.test(maybeEmail);

Если вам интересны подробности о том, как это работает — вот ссылка на документацию.

Производные от типов​

Однажды, уже спускаясь с высокой горы, Ученик спросил у Мастера: — зачем нужен TypeScript, если его не существует в RunTime? В ответ Мастер толкнул Ученика и тот, не устояв на ногах, с криком покатился вниз. Не дожидаясь, пока крики стихнут, Мастер продолжил путь.

С примитивами мы разобрались, теперь я хочу показать еще одну возможность, которой часто пользуюсь — создание нового типа на основе существующего. В ООП мы обычно используем наследование, а TypeScript позволяет трансформировать одни типы в другие. Наверняка вы уже использовали тип Partial<T> или Required<T>, которые идут сразу из коробки. Но кроме встроенных типов мы можем создавать и свои, например, вот так:

type NotEmptyString = string;
type User = { name: NotEmptyString };
type Dto<T> = { [key in keyof T]: unknown };
type UserDto = Dto<User>;

Сначала я описал доменную модель, с которой готов работать. Для этого примера я создал тип User с единственным полем name, которое должно содержать не пустую строку. Но, поскольку я не очень-то доверяю постороннему сервису, из которого приходят данные, я хочу ответ от сервера провалидировать. Для этого я создал производный тип UserDto, в котором честно объявил, что понятия не имею, что же нам пришлют. Теперь я, а также все, кто придет после, будем вынуждены проверить данные перед их использованием.

Естественно, с помощью этого подхода можно написать и схему валидатора.

Шаг 1. Создаем обобщенный тип, который содержит все те же поля, что и будущий тип T, а значениями будут функции, которые принимает аргумент неизвестного типа, и возвращают признак принадлежности аргумента заданному типу.

type DtoValidator<T> = {
[key in keyof T]: (v: unknown) => v is T[key];
};

Шаг 2. Пишем валидатор в форме guard-а, в котором проверяем переданный аргумент. Если он удовлетворяет заданные условия, мы признаем, что он принадлежит типу NotEmptyString.

const isNotEmptyString = (v: unknown): v is NotEmptyString =>
typeof v === "string" && v.length > 0;

Шаг 3. Собираем все вместе в схему валидации. Обратите внимание на то, что теперь уже TS будет следить за тем, чтобы в схеме все поля были описаны и корректно заполнены. Если мы что-то упустим, TypeScript выдаст ошибку.

const userValidator: DtoValidator<User> = {
name: isNotEmptyString,
};

Самое прекрасное здесь то, что когда мы расширим доменную модель новым полем, TS автоматически напомнит нам, что это поле тоже нужно взболтать проверить.

На моем проекте мы используем этот подход для валидации форм. Есть модель формы, а на ее основе строится модель валидатора. Когда поля формы меняются (что периодически бывает), TypeScript подсказывает, где мы прошляпили.

Динамическая модификация типов​

Однажды, гуляя ухоженным монастырским парком, Ученик спросил у Мастера: — Зачем нужен TypeScript, если его можно заменить на JSDoc? В ответ Мастер указал на пирамиду стоящих друг на друге камней и попросил вопрошающего выбить нижний камень. Ученик всей силой навалился на основание. Увидев, как шатается их любимая скульптура, монахи избили Ученика палками.

Этот трюк может быть особенно актуален, если вы используете контексты в React, но сама идея будет работать везде. TS позволяет выводить новые типы «на лету», исходя из данных, которые вы передаете. Это может звучать немного запутанно, так что давайте посмотрим на примере.

Напишем функцию, которая приветствует пользователя:

type User = { firstName: string };
type Greetings = { greetingText: string };
const getGreetings = ({ firstName, greetingText }: User & Greetings) =>
`${greetingText}, ${firstName}!`;

Очевидно, что имя пользователя у нас появится в рантайме, а вот текст самого приветствия может быть статичным. Можно написать функцию высшего порядка, которая будет внедрять greetingText в getGreetings.

const greeterFactory = () => (user: User) =>
getGreetings({ ...user, greetingText: "Hello" });
const greeter = greeterFactory();
greeter({ firstName: "Vitalii" });

Все хорошо, но это частное решение, а хотелось бы иметь общее, которое бы:

  • Работало с произвольными типами.
  • Убирало из требуемого типа те поля, которые уже содержаться во внедренном объекте.
Наивная реализация могла бы выглядеть так:

function factory<TModel, TResult, TInjected extends Partial<TModel>>(
callback: (m: TModel) => TResult,
inject: TInjected
) {
return (m: Omit<TModel, keyof TInjected>) => callback({ ...inject, ...m });
}

Однако компилироваться она не будет, потому что я допустил любопытную ошибку:

factory((m: { greeting: string; name: string }) => m.name, {
greeting: "hello",
name: "Vitalii",
})("Joker");

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

// I am not a monster
function factory<
TCallback extends (arg: any) => any,
TModel extends Parameters<TCallback>[0],
TInjected extends Partial<TModel>
>(callback: TCallback, injected: TInjected) {
return function <TProps extends Omit<TModel, keyof TInjected>>(
props: TProps extends object ? TProps : never
): ReturnType<TCallback> {
return callback({ ...injected, ...props });
};
}
const greeter = (_: { greeting: string; name: string }) => "";
// "Argument of type 'string' is not assignable to parameter of type 'never'"
const failed = factory(greeter, {
greeting: "hello",
name: "Vitalii",
})("Joker");
// // Works with full IntelliSense support.
const working = factory(greeter, {
greeting: "hello",
})({ name: "test" });

Теперь все работает, как ожидается, хотя есть нюанс. Во-первых, читается это сложно, хотя идея тут довольно простая и строится вокруг возможности TypeScript-а извлекать типы из функций и служебного типа Omit. А во-вторых, в рантайме, как в injected, так и в props, может попасть объект с куда большим количеством полей и это стоит иметь ввиду.

Для нас этот подход пригодился, когда мы писали свои коннекторы к хранилищу данных в React. В результате, в компонент нужно было передавать только те поля, которых нет в сторе, остальные он «возьмет» сам, а TypeScript проверит, что мы не забыли пробросить недостающие. Поскольку все объекты под нашим контролем, нюанс, о котором я упоминал, проблемой не был.

Итоги подведем​

Как видите, TypeScript — это нечто большее, чем просто аннотация типов. Он позволяет создавать подмножества, проекции, выводить типы на лету и многое другое, о чем я не упоминал. Несмотря на то, что в runtime TypeScript отсутствует, правильно написанные типы упрощают разработку и могут уберечь от промахов.

С другой стороны, с виду очевидные решения на TypeScript не работают. Ошибки бывают непонятны и не очевидны, особенно в начале работы с языком (да и чего таить греха — потом тоже). Старт проекта тоже идет медленнее, так как нужно спроектировать и описать типы, а потом еще и исправить там, где ты промахнулся.

И все же, удобства, которые обеспечивает TypeScript, того стоят. Знакомить новых людей с проектом и даже самому возвращаться к модулю, с которым ты не работал хотя бы месяц — намного легче и приятнее. Главное — это сохранять баланс, как говорил главный персонаж одной очень философской игры. Если вы видите, что TS усложняет поддержку вашего кода — значит кто-то свернул не туда.

Всем хороших праздников!

 
Сверху