Кастомные типы данных в TypeScript: валидация на этапе компиляции
Рассказываем, как система типов в TypeScript позволяет создавать кастомные ошибки компиляции с помощью дженериков, множеств типов и type maps.
15К открытий16К показов
Типы данных в TypeScript придают языку строгость и ощущение порядка, которого недостаёт в JavaScript. В этой статье мы рассмотрим кастомные ошибки компиляции, нетривиальные способы использования дженериков, множества типов и type map`ы.
Содержание:
- Принудительные ошибки компиляцииКастомные ошибки компиляцииНесколько слов о дженерикахМножества типовType MapsType BuildersСдвиг влево и type builderДля чего всё это нужно?
Принудительные ошибки компиляции
Вы можете намеренно вызывать ошибку компиляции, когда нарушаются некие правила. Например, если в коде объявляется переход машины состояний в несуществующее состояние. Другой пример, когда нужно написать функцию, которая отказывается принимать объект без ключа «pony». Для этого можно написать простое обобщённое ограничение.
Это сообщение в понятном виде сообщает, что мы не передали ключ pony. В более сложных случаях ошибки могут стать настолько непонятными, что не получится сразу разобраться, что вы сделали не так. Кроме того, ограничения типов не будет достаточно для чтого, чтобы сделать обратное: отклонять объект имеющий ключ pony.
Простой условный тип поможет нам уберечь нашу функцию от pony:
Почему это работает? never — это специальный тип, который обычно используется для определения типа значения в невозможной ситуации: default в свитче, который никогда не вызовется, или тип возвращаемого значения функции, которая не вернёт значение. У него есть и другие способы применения, о которых мы поговорим далее. never это нижний тип с двумя свойствами:
- Ни один тип не может расширять never, кроме его самого, следовательно к never можно присвоить только never.
- never расширяет все типы, и может быть присвоен к любому типу.
Согласно первому свойству в примере выше произойдёт ошибка компиляции: вы пытаетесь присвоить тип T к never, потому что выполнено условие — T имеет ключ pony. Заметим, в этом примере исключаются любые pony, вне зависимости от их типа. В случае с дженерик ограничением исключаются только pony типа string. Этот трюк с приведением к never вызывает ошибку компиляции, кроме случая, когда T является never. Если это так, присваивание будет успешным, согласно второму свойству never.
Кастомные ошибки компиляции
Чтобы помочь разработчикам, которые будут использовать наше API, мы можем сделать ошибки более информативными. Для этого будем использовать технику, которая называется брендинг:
Применяя void к типу, мы гарантируем, что ему ничего не может быть присвоено. Следовательно, присваивание не удастся, если пользователи не используют преобразование типов.
В этом сообщении об ошибке можно проигнорировать всё, кроме “pony запрещены
“, из которого становится понятно, что нужно сделать. Сообщение об ошибке можно сделать ещё чище с помощью типов исключений.
Есть другой способ — попытка присвоения к строковому литералу. Это сработает в большинстве случаев. Сообщение об ошибке будет чище, однако, здесь есть крайний случай:
В этом примере ‘Not a pony’ не расширяет ‘dog’. Вместо того, чтобы становиться типом T, тип привязывается к ‘Not a pony’ . Однако, так как тип ‘Not a pony’ присваиваем к типу ‘Not a pony’, вы не получите ошибку компиляции.
Несколько слов о дженериках
Те кто знаком с TypeScript (или Java или C#) знают, что дженерики служат для того, чтобы абстрагироваться от конкретного типа, например:
Эта простая функция возвращает переданное ей значение без изменений. Тип возвращаемого значения такой же, как и тип входного параметра.
Дженерики в TS обладают множеством возможностей. Вот некоторые их свойства.
Количество типов аргументов не обязательно должно совпадать с количеством параметров.
Этот пример показывает, что дженерики можно применять не только для замены типов один к одному. Данное заблуждение есть не только в TypeScript: Java и Rust (среди прочих) позволяют строить сложные типы из дженериков. Например, MapEntry
Функции могут возвращать функции дженерики.
В этом примере мы каррируем параметры дженерики, вместо параметров функции. Затем вызываем foo где тип x — 8, а тип y — 7 (литералы типов).
При объявлении функции, тип аргументов можно указывать не только слева от =:
В этом примере тип foo определяется, когда мы в первый раз вызываем его с параметрами (x: 5, y: 7) => number.
При объявлении аргумента в левой части =, параметр типа преобразуется в конкретный тип, когда мы присваиваем значение переменной типа. При объявлении аргумента типа в правой части, мы откладываем разрешение до конкретного типа до тех пор, пока не вызовем функцию.
Кроме того, присвоения типов, указанных в правой части, сохраняются только для одного вызова. Во время второго вызова параметр типа U foo принимает значение 8. Третий вызов не удается скомпилировать, потому что мы уже привязали T к 5, когда назначили foo в строке 2.
Множества типов
Множество – это коллекция элементов. Их количество может быть неограниченным. Исторически теория типов и теория множеств имеют взаимосвязанную родословную и общую цель: быть фундаментальным математическим атомом, на основе которого вы можете вывести каждое доказуемое утверждение.
Тип описывает набор всех значений, присваиваемых типу, поэтому пересечения и объединения типов могут сбивать с толку. Эта путаница возникает из-за того, что типы в Typescript также являются множествами других типов. Тип строки — это бесконечное множество всех строк, в то время как число — это конечное множество всех double из IEEE-754 (кроме NaN и Infinity). В дополнение к встроенным типам, мы можем создавать свои конечные множества, используя оператор |.
Короче говоря, для того, чтобы множества были нам полезны, потребуется несколько операций. Во-первых, нам нужно уметь описывать ∅ — пустое множество. Пустое множество аналогично нулю для теории множеств. Пустое множество определяется следующим свойством:
Если вы скомбинируете пустое множество с любым множеством, ничего не произойдёт.
В Typescript есть такой тип, который ведет себя как ∅ — never. В приведенном ниже примере мы демонстрируем (но не доказываем) некоторые из этих свойств на произвольных типах:
Мы обернули тип T в кортеж, чтобы остановить распределение типов.
Также нам нужна возможность проверить, что что-то является подмножеством другого множества. Мы делаем это с помощью ключевого слова extends в условном типе.
Последнее, что нам нужно, чтобы с пользой применять множества в Typescript — это способ их создания путем добавления элементов. Оператор | делает именно это.
Теперь мы можем свободно использовать типы TypeScript в качестве множеств:
Type Maps
Type maps позволяют связывать ключи (строки, числа и символы) с типом.
Особенности type maps:
- В отличие от множеств, где мы используем | для добавления элементов, здесь используется &(пересечение).
- В отличие от большинства value maps (например, Dictionary или Map в других языках), где вы можете запросить только одно значение за раз, в TypeScript type maps позволяют запрашивать несколько значений, как в строке с CowOrCat. В этом случае вы получаете множество типов.
- В крайнем случае, вы можете запросить TagMap[keyof TagMap] и получить все типы.
Вы также можете присвоить ключ нескольким типам.
Для присваивания ключа нескольким типам таким способом требуются вложенные условные типы, что при слишком глубокой рекурсии может привести к ошибкам. Существуют другие конструкции, не требующие рекурсивных условий.
Как множества типов, так и type maps сами по себе довольно мощные, но довольно неудобные для построения. Кроме того, они не очень динамичны, так как вы получаете все, что объявлено статически. Но что делать, если нам нужно что-то более изменчивое?
Type Builders
Каждый, кто программировал на любом языке, который использует классы и интерфейсы (Java, C#), сталкивался с паттерном строитель. Строители — это конструкторы, которые допускают более гибкую семантику, чем функция. В этих языках строители, как правило, представляют собой класс, который изменяет себя при вызове его функций, а при вызове build он действует как фабрика и создает объект в соответствии с вашей спецификацией. Однако есть и другой способ реализовать этот шаблон без классов или мутабельности.
Вместо этого мы можем использовать частичное применение функции:
Ключом к частичному применению паттерна строитель является создание функций, возвращающих функции, которые близки к определению. Это позволяет нам определить строитель, который обладает преимуществами по сравнению с его версией на основе классов:
- Операции, которые вы можете выполнять, могут варьироваться в зависимости от того, где вы находитесь в конструкторе. В нашем примере вы не можете вызвать done, пока не добавите хотя бы одно число.
- Интересным следствием этого является то, что тип конструктора изменяется по мере выполнения различных операций. Чтобы сделать это с помощью строителей на основе классов, требуется несколько классов и тонна шаблонного кода.
- Конструктор никогда ничего не изменяет, мы копируем базовое определение с новым значением, используя оператор spread. Это позволяет нам удостовериться в правильности работы на каждом этапе работы строителя, зная, что они действуют изолированно.
- Менее полезное на практике преимущество состоит в том, что вы можете в процессе создать fork строителя, и они не будут мешать друг другу.
Однако вы не знаете, что сделали что-то не так, пока не запустите код, а это означает, что вам нужно написать тесты, и баги могут добраться до пользователей. Что если бы был способ проверить всё во время компиляции?
Сдвиг влево и type builder
Сдвиг влево это метод тестирования, когда тесты проводятся на ранней стадии жизни проекта, чтобы предотвратить позднее обнаружение ошибок. В предыдущем примере мы проверяем, что в функцию не передано никаких чисел кроме 5 или 7. При нарушении этого правила мы получим исключение. Однако есть способ получше:
Этот пример строителя базируется на некоторых из ранее описанных техник, чтобы превратить исключение во время выполнения в ошибку компиляции. Мы используем все три наших наблюдения над дженериками:
- В строке 26 дженерик строитель функций возвращает функции дженерики.
- В строке 15 типы аргументов дженериков определяются слева и справа от =.
- Мы определяем больше типов параметров, чем параметров в строке 36.
Наш предыдущий строитель менял свой тип, чтобы вы могли вызывать различные функции в зависимости от того, где вы находитесь в строителе. Эта версия делает то же самое, но также изменяет свой тип, чтобы создать множество типов чисел. Обратите внимание, что это набор типов чисел, а не набор значений чисел! Мы делаем это, инициализируя набор чисел с never (т. е. пустой набор) в частичном вызове addNumber внутри определения makeAThing (строка 11).
Каждый раз, когда вы вызываете addNumber в строителе, мы возвращаем новый строитель, множество Nums которого содержит новый T, переданный вами при передаче x. Возвращаемое значение в строке 18 способствует этому и, соответствующая реализация в строке 26 должна совпадать, чтобы мы не получили ошибку компиляции.
В вызове строителя типов мы используем набор типов Nums, чтобы проверить две вещи:
- Вы не может передать значение отсутствующее в Nums в функцию валидации возвращаемую конструктором. Иначе вы получите ошибку компиляции в 52 строке.
- Вы не можете добавить число дважды. Иначе вы получите ошибку компиляции в 59 строке.
Для чего всё это нужно?
Конструкторы типов в TypeScript позволяют конечным пользователям создавать очень сложные типы, построенные с использованием довольно простого шаблона.
Автор статьи написал библиотеку ts-checked-fsm для написания конечных автоматов в TypeScript. Используя методы описанные в этой статье (и разумное использование void, не обсуждаемое здесь), ts-checked-fsm позволяет пользователям объявлять конечный автомат и гарантировать во время компиляции, что он внутренне согласован. Примеры проверок:
- Вы не можете определить состояние дважды.
- Переходы возможны только между определёнными состояниями.
- Обработчики действий должны возвращать только состояния, которые соответствуют определённым переходам.
- Вы должны определить как минимум обработчик событий для каждого не финального состояния.
Нарушение любого из этих правил приводит к тому, что код не компилируется, и в вашей среде IDE появляются красные пометки. Кроме того, обработчики действий правильно типизированы, а метки состояния и действия определяют типы в обратном вызове обработчика.
Еще одним менее очевидным преимуществом всего этого является то, что выполняя проверки во время компиляции, вы можете опустить их во время выполнения. Это приводит к уменьшению количества кода и увеличению его быстродействия. Действительно, ts-checked-fsm добавляет только 565 байт в ваше приложение после минимизации и сжатия.
Кроме этого автор написал библиотеку для создания наблюдателей на redux-sagax. Её можно использовать для избавления от багов в многопоточном коде.
15К открытий16К показов