Обложка: Кастомные типы данных в TypeScript: валидация на этапе компиляции

Кастомные типы данных в TypeScript: валидация на этапе компиляции

Типы данных в TypeScript придают языку строгость и ощущение порядка, которого недостаёт в JavaScript. В этой статье мы рассмотрим кастомные ошибки компиляции, нетривиальные способы использования дженериков, множества типов и type map`ы.

Содержание:

        1. Принудительные ошибки компиляции
        2. Кастомные ошибки компиляции
        3. Несколько слов о дженериках
        4. Множества типов
        5. Type Maps
        6. Type Builders
        7. Сдвиг влево и type builder
        8. Для чего всё это нужно?

Принудительные ошибки компиляции

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

const yayPonies = <T extends {ponies: 'yay'}>(ponylike: T) => {}
// Аргумент типа 'string' не соответствует параметру 
// '{ ponies: "yay"; }'.(2345)
yayPonies('no ponies :(')

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

Простой условный тип поможет нам уберечь нашу функцию от pony:

const booPonies = (p: keyof T extends 'pony' ? never : T) =>
{}
booPonies('нет pony') //Ок

// Тип 'string' не соответствует типу 'never'.(2322)
booPonies({pony: 'иго-го'})

Почему это работает? never — это специальный тип, который обычно используется для определения типа значения в невозможной ситуации: default в свитче, который никогда не вызовется, или тип возвращаемого значения функции, которая не вернёт значение. У него есть и другие способы применения, о которых мы поговорим далее. never это нижний тип с двумя свойствами:

  • Ни один тип не может расширять never, кроме его самого, следовательно к never можно присвоить только never.
  • never расширяет все типы, и может быть присвоен к любому типу.

Согласно первому свойству в примере выше произойдёт ошибка компиляции: вы пытаетесь присвоить тип T к never, потому что выполнено условие — T имеет ключ pony. Заметим, в этом примере исключаются любые pony, вне зависимости от их типа. В случае с дженерик ограничением исключаются только pony типа string. Этот трюк с приведением к never вызывает ошибку компиляции, кроме случая, когда T является never. Если это так, присваивание будет успешным, согласно второму свойству never.

Кастомные ошибки компиляции

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

type ErrorBrand = Readonly<{
  [key in Err]: void;
}>;
const booPonies = (
  ponies: keyof T extends 'pony' 
    ? ErrorBrand<'pony запрещены!'> 
    : T
) => {}
booPonies('нет pony') // Good
booPonies({pony: 'иго-го'}) // ヽ(ಠ_ಠ)ノ

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

Аргумент типа '{ pony: string; }' не может быть присвоен к 
параметру типа 'Readonly<{ "pony запрещены!": void; }>'.
  Литерал объекта может определять только существующией свойства, и 'pony' 
не существует в типе 'Readonly<{ "pony запрещены!": void; }>'.(2345)

В этом сообщении об ошибке можно проигнорировать всё, кроме «pony запрещены«, из которого становится понятно, что нужно сделать. Сообщение об ошибке можно сделать ещё чище с помощью типов исключений.

Есть другой способ — попытка присвоения к строковому литералу. Это сработает в большинстве случаев. Сообщение об ошибке будет чище, однако, здесь есть крайний случай:

type AssertIsAnimal = T extends 'dog' | 'pony' ? T : 'Not a pony';
const barn = (animal: AssertIsAnimal) => { }
barn('Not a pony') // Скомпилируется потому что T присваивается к
                   // ветке "failure" .

В этом примере ‘Not a pony’ не расширяет ‘dog’. Вместо того, чтобы становиться типом T, тип привязывается к ‘Not a pony’ . Однако, так как тип ‘Not a pony’ присваиваем к типу  ‘Not a pony’, вы не получите ошибку компиляции.

Несколько слов о дженериках

Те кто знаком с TypeScript (или Java или C#) знают, что дженерики служат для того, чтобы абстрагироваться от конкретного типа, например:

const identity = (val: T): T => { return val; }
const y = identity(7);

Эта простая функция возвращает переданное ей значение без изменений. Тип возвращаемого значения такой же, как и тип входного параметра.

Дженерики в TS обладают множеством возможностей. Вот некоторые их свойства.

Количество типов аргументов не обязательно должно совпадать с количеством параметров.

type MyThing<T, U> =  T & U;
type foo = <T,U,V,W,X,Y,Z>(x: MyThing<T, U> extends V ? W: X) => Y|Z; // Ок

Этот пример показывает, что дженерики можно применять не только для замены типов один к одному. Данное заблуждение есть не только в TypeScript: Java и Rust (среди прочих) позволяют строить сложные типы из дженериков. Например, MapEntry<K, V> в Java. Однако, в TypeScript это даёт нам ещё больше возможностей, потому что в дженериках можно использовать объединения, пересечения, условия,infer и другие операции с типами.

Функции могут возвращать функции дженерики.

const f = <X extends number>() => {
  return <Y extends number>(x: X, y: Y) => {
    return x + y;
  }
}
const foo = f<8>();
// typeof foo is (x: 8, y: 7) => number
foo(8, 7);

В этом примере мы каррируем параметры дженерики, вместо параметров функции. Затем вызываем foo где тип x — 8, а тип y — 7 (литералы типов).

При объявлении функции, тип аргументов можно указывать не только слева от =:

type Foo<T> = <U extends number>(x: T, y: U) => number;
const foo: Foo<5> = (x, y) => x + y;
foo(5, 7); // Ок
foo(5, 8); // Ок
foo(6, 8); // Ошибка, тип 6 не расширяет тип T=5

В этом примере тип foo определяется, когда мы в первый раз вызываем его с параметрами (x: 5, y: 7) => number.

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

Кроме того, присвоения типов, указанных в правой части, сохраняются только для одного вызова. Во время второго вызова параметр типа U foo принимает значение 8. Третий вызов не удается скомпилировать, потому что мы уже привязали T к 5, когда назначили foo в строке 2.

Множества типов

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

Тип описывает набор всех значений, присваиваемых типу, поэтому пересечения и объединения типов могут сбивать с толку. Эта путаница возникает из-за того, что типы в Typescript также являются множествами других типов. Тип строки — это бесконечное множество всех строк, в то время как число — это конечное множество всех double из IEEE-754 (кроме NaN и Infinity). В дополнение к встроенным типам, мы можем создавать свои конечные множества, используя оператор |.

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

∀ 𝐀: 𝐀 ⋃ ∅ = 𝐀

Если вы скомбинируете пустое множество с любым множеством, ничего не произойдёт.

В Typescript есть такой тип, который ведет себя как ∅ — never. В приведенном ниже примере мы демонстрируем (но не доказываем) некоторые из этих свойств на произвольных типах:

// Демонстрирует ∀ 𝐀: 𝐀 ⋃ ∅ = 𝐀
type A<T> = [T | never] extends [T] ? true : false;
type <T> = [T] extends [never | T] ? true : false;
type Aa = A<'a' | 'b'> & B<'a' | 'b'>; // true

// Демонстрирует ∀ 𝐀: 𝐀 ⋂ ∅ = ∅
type C<T> = [T & never]
type Ca = C<'a' | 7>; // [never]

// Демонстрирует ∀ 𝐀: ∅ ⊆ 𝐀
type D<T> = [never] extends [T] ? true : false;
type E = [never] extends [never] ? true : false;
type Da = D<'a' | symbol | 7> & E // true

Мы обернули тип T в кортеж, чтобы остановить распределение типов.

Также нам нужна возможность проверить, что что-то является подмножеством другого множества. Мы делаем это с помощью ключевого слова extends в условном типе.

Последнее, что нам нужно, чтобы с пользой применять множества в Typescript — это способ их создания путем добавления элементов. Оператор | делает именно это.
Теперь мы можем свободно использовать типы TypeScript в качестве множеств:

type Thing = 'foo' | 7 | true;

type ErrorBrand<Err extends string> = Readonly<{
  [key in Err]: void;
}>;
function a<T>(
  x: T extends Thing ? T : ErrorBrand<'Параметр не является Thing'>
) {
}
a(7); // OK, {7} ⊆ Thing
const x = Math.random() < .5 ? 7 : true;
a(x); // OK, {7, true} ⊆ Thing
// Ошибка, потому что 6∉Thing: Аргумент типа 'number' не 
// присваиваем параметру 
// 'Readonly<"Parameter is not a Thing": void; }>'.
a(6);

Type Maps

Type maps позволяют связывать ключи (строки, числа и символы) с типом.

type Thing<T extends string | number | symbol, U> = { tag: T } & U;

type TagMapEntry<T extends string | number | sybmol, U> = { 
  [key in T]: Thing<T, U> 
};

type TagMap = 
  TagMapEntry<'cow', {moo: 'milk'}> & 
  TagMapEntry<'cat', {meow: 'notmilk'}>;

type Cow = TagMap['cow']; // Cow = Thing<'cow', {moo: 'milk'}>
type Cat = TagMap['cat']; // Cat = Thing<'cat', {meow: 'notmilk'}>
type CowOrCat = TagMap['cat' | 'cow']; // CowOrCat = Cat | Cow
type Dog = TagMap['dog']; // Ошибка, в map нет dog

Особенности type maps:

  • В отличие от множеств, где мы используем | для добавления элементов, здесь используется  &(пересечение).
  • В отличие от большинства value maps (например, Dictionary или Map в других языках), где вы можете запросить только одно значение за раз, в TypeScript type maps позволяют запрашивать несколько значений, как в строке с CowOrCat. В этом случае вы получаете множество типов.
  • В крайнем случае, вы можете запросить TagMap[keyof TagMap] и получить все типы.

Вы также можете присвоить ключ нескольким типам.

type AddKeyToMap<M, K extends string | number | symbol, V> =
  Omit<M, K> & { [key in K]: M[Extract<K, keyof M>] |  V }

type Map1 = AddKeyToMap<{}, 'cat', 'meow'>;
type Map2 = AddKeyToMap<Map1, 'cat', 'purr'>;
type Cat = Map2['cat']; // 'meow' | 'purr'

Для присваивания ключа нескольким типам таким способом требуются вложенные условные типы, что при слишком глубокой рекурсии может привести к ошибкам. Существуют другие конструкции, не требующие рекурсивных условий.

Как множества типов, так и type maps сами по себе довольно мощные, но довольно неудобные для построения. Кроме того, они не очень динамичны, так как вы получаете все, что объявлено статически. Но что делать, если нам нужно что-то более изменчивое?

Type Builders

Каждый, кто программировал на любом языке, который использует классы и интерфейсы (Java, C#), сталкивался с паттерном строитель. Строители — это конструкторы, которые допускают более гибкую семантику, чем функция. В этих языках строители, как правило, представляют собой класс, который изменяет себя при вызове его функций, а при вызове build он действует как фабрика и создает объект в соответствии с вашей спецификацией. Однако есть и другой способ реализовать этот шаблон без классов или мутабельности.

Вместо этого мы можем использовать частичное применение функции:

type Definition = {
    vals: number[]
}

// Точка входа нашего строителя. Инициализируется пустой
// список чисел и возвращается builder, который позволяет
// вызвать addNumber.
const makeAThing = () => {
    const definition: Definition = {
        vals: []
    };

    return {
        addNumber: addNumber(definition)
    }
}

// addNumber позволяет добавлять разрешенное число
// в defenition которое может быть проверено в рантайме
// функцией валидации
const addNumber = (definition: Definition) => {
    return (x: number) => {
        const newDefinition: Definition = {
            vals: [
                ...definition.vals,
                x
            ]
        };

        return {
            addNumber: addNumber(newDefinition),
            done: done(newDefinition)
        };
    }
}

// Функция, которая возвращает функцию, которая в свою очередь
// возвращает функцию, которая проверяет наличие x
// в списке значений.
const done = (definition: Definition) => {
    return () => {
        return (x: number) => {
            if (!definition.vals.some(val => val === x)) {
                throw new Error(`Value ${x.toString()} not allowed`);
            }
        }
    };
}

// Позволяет передавать 5 и 7 в myBuilder
const myBuilder = makeAThing()
    .addNumber(7)
    .addNumber(5)
    .done();

myBuilder(5); // Ok
myBuilder(7); // Ok
myBuilder(8); // Иключение

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

  • Операции, которые вы можете выполнять, могут варьироваться в зависимости от того, где вы находитесь в конструкторе. В нашем примере вы не можете вызвать done, пока не добавите хотя бы одно число.
  • Интересным следствием этого является то, что тип конструктора изменяется по мере выполнения различных операций. Чтобы сделать это с помощью строителей на основе классов, требуется несколько классов и тонна шаблонного кода.
  • Конструктор никогда ничего не изменяет, мы копируем базовое определение с новым значением, используя оператор spread. Это позволяет нам удостовериться в правильности работы на каждом этапе работы строителя, зная, что они действуют изолированно.
  • Менее полезное на практике преимущество состоит в том, что вы можете в процессе создать fork строителя, и они не будут мешать друг другу.

Однако вы не знаете, что сделали что-то не так, пока не запустите код, а это означает, что вам нужно написать тесты, и баги могут добраться до пользователей. Что если бы был способ проверить всё во время компиляции?

Сдвиг влево и type builder

Сдвиг влево это метод тестирования, когда тесты проводятся на ранней стадии жизни проекта, чтобы предотвратить позднее обнаружение ошибок. В предыдущем примере мы проверяем, что в функцию не передано никаких чисел кроме 5 или 7. При нарушении этого правила мы получим исключение. Однако есть способ получше:

type Definition = {
    vals: number[]
}

type ErrorBrand<Err extends string> = Readonly<{
  [k in Err]: void;
}>;

// Точка входа строителя. В отличие строителя значений, 
нам не нужно определение значений хранящее
// добавленные числа потому что бы будем хранить их в типе и проверять
// на стадии компиляции.
const makeAThing = () => {
    // Можно вызвать addNumber на следующем шаге строителя.
    // Инициализирует множество Nums пустым множеством.
    return {
        addNumber: addNumber<never>()
    }
}

type AddNumberBuilder<Nums extends number> = {
    // Когда пользовать вызывает addNumber(x: T), проверят что T уже не
    // присутствует в Nums. Если нет, выполнение продолжается
    // и T добавляется в Nums.
    addNumber: (
      x: T extends Nums ? ErrorBrand<'Число уже существует'> : T
    ) => AddNumberBuilder,
    
    // Завершает builder, возвращает функцию, которая проверяет что x
    // существует в Nums.
    done: () => <T extends number> (
      x: T extends Nums ? T : ErrorBrand<'Не корректное число'>
    ) => void
}

// Чтобы реализовать частично примененный строитель, функция addNumber
// возвращает функцию, которая возвращает AddNumberBuilder.
const addNumber = <Nums extends number> () => {
    return <T extends number> (
        x: T extends Nums ? ErrorBrand<'Число уже существует'> : T
    ): AddNumberBuilder<Nums | T> => {
        return {
            addNumber: addNumber<Nums, T>(),
            done: done()
        };
    }
}

// Эта функция возвращает функцию которая возвращает функцию валидации.
const done = <Nums extends number>() => {
    return () => {
        return <T extends number>(
            x: T extends Nums ? T : ErrorBrand<'Некорректное число'>
        ) => {
        }
    };
}

const myBuilder = makeAThing()
    .addNumber(7)
    .addNumber(5)
    .done();

myBuilder(5);
myBuilder(7);
myBuilder(8); // Аргумент типа 'number' не возможно присвоить параметру типа
              // 'Readonly<{ "Некорректное чисто": void; }>'.(2345)

const myOther = makeAThing()
    .addNumber(7)
    .addNumber(5)
    .addNumber(5) // Аргумент типа 'number' не возможно присвоить параметру типа
                  // 'Readonly<{ "Число уже существует": void; }>'.(2345)
    .done();

Этот пример строителя базируется на некоторых из ранее описанных техник, чтобы превратить исключение во время выполнения в ошибку компиляции. Мы используем все три наших наблюдения над дженериками:

  • В строке 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. Её можно использовать для избавления от багов в многопоточном коде.

Источник Really Advanced Typescript Types