Нецелевое использование утверждающих функций в TypeScript

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Это ужасный (но очень полезный) хак, который я придумал для добавления типов в старый код. Вчера мой коллега, работающий над добавлением типов в одну из наших основных библиотек на LinkedIn, спросил меня, как быть со старым (и уже не рекомендуемым) паттерном. В качестве одного из вариантов решения мы попробовали применить утверждающую функцию. вразрез с её предназначением. В конечном итоге нам не удалось добиться конкретно желаемого 1, но мне этот паттерн показался достаточно интересным, чтобы им поделиться.

Мотивация


Предположим, у вас есть старый JS API, который зависит от мутирования передаваемого ему объекта. В идиоматическом TS я бы порекомендовал создать полностью новый объект, используя некую форму композиции – декорирование, делегирование и т.д. Однако в некоторых сценариях нельзя внести изменения, не нарушив работу множества потребителей, в связи с чем необходимо предоставить рабочий TS API (возможно, на время создания более подходящего API для перехода). В таком случае можно использовать функцию asserts для моделирования этого поведения в системе типов.

Утверждающие функции


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

assert(someCondition, "Message if it fails");

В данном случае, если someCondition окажется ложен, функция вместо возвращения результата выбросит ошибку. TS позволяет нам смоделировать такое поведение путём указания, что функция утверждает условие, представленное someCondition:

declare function assert(value: unknown, error: string | Error): asserts value;

То есть она утверждает, что аргумент value должен быть true, и в противном случае — результат не возвращает. После вызова assert TS благодаря анализу потока управления знает, является ли переданный предикат истинным. Этот приём можно использовать с любыми предикатами, чтобы получить больше информации о типах, с которыми вы работаете:

function rejectNonStrings(value: unknown) {
  assert(typeof value === 'string', "It wasn't a string!");
  // Теперь этот тип проходит проверку, потому что TS знает, что ‘value’ является `string`:
  console.log(value.length);
}

Этого базового примера для целей статьи вполне хватит: теперь у нас есть достаточно информации, чтобы понять, как можно использовать asserts не по назначению для решения совершенно иной задачи. Если вы хотите углубиться в тему более детально, ознакомьтесь с соответствующей документацией Microsoft и статьёй Мариуса Шульца «Assertion Functions in TypeScript».

Нецелевое применение


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

class Person {
  constructor(age, name) {
    this.age = age;
    this.name = name;
  }
}

function addAddress(person, address) {
  person.address = address;
}

let me = new Person(34, 'Chris');
addAddress(me, '1234 Some St., Example City, CO 00000');
console.log(me.address);

При изначальном преобразовании этого кода в TS компилятор сообщит нам, что реализация addAddress небезопасна.

class Person {
  age: number;
  name?: string | undefined;

  constructor(age: number, name?: string | undefined) {
    this.age = age;
    this.name = name;
  }
}

function addAddress(person: Person, address: string): void {
  person.address = address;
  //     ^^^^^^^ Свойство 'address' не существует в типе 'Person'.
}

let me = new Person(34, 'Chris');
addAddress(me, '1234 Some St., Example City, CO 00000');
console.log(me.address);
//             ^^^^^^^ Свойство 'address' не существует в типе 'Person'.

Можно ввести интерфейс, который будет представлять Person с адресом, и выполнить безопасное «расширяющее» приведение типа:

class Person {
  // образец реализации
}

interface PersonWithAddress extends Person {
  address: string;
}

function addAddress(person: Person, address: string) {
  // БЕЗОПАСНОСТЬ: TS допустит это, только если `person` *может* быть сужен или расширен до
  // этого типа. Сужение окажется небезопасным; расширение же строго безопасно,
  // но не в том смысле, в котором его поддерживает TS. Код остаётся безопасным только потому, что мы
  // сразу инициализируем полностью новые поля.
  (person as PersonWithAddress).address = address;
}

Работает! …но только внутри тела функции. На стороне вызова тот факт, что элемент Person теперь содержит поле address, по-прежнему остаётся незаметен:

console.log(me.address);
//             ^^^^^^^ Свойство 'address' не существует в типе 'Person'.

Именно здесь мы прибегаем к приёму asserts, которому и посвящена статья. Можно обновить addAddress, утвердив, что передаваемый person фактически является типом PersonWithAddress:

function addAddress(
  person: Person,
  address: string
): asserts person is PersonWithAddress {
  (person as PersonWithAddress).address = address;
}

Теперь при вызове addAddress TS узнаёт о существовании поля address:

addAddress(me, '1234 Some St., Example City, CO 00000');
console.log(me.address);

Всё благодаря утверждению, что вызов addAddress также указывает на наличие в me поля адреса. Заметьте, что это не совсем верно…но по факту соответствует правильной семантике. Если хотите поиграться сами, то можете открыть этот пример в песочнице TS.

Оговорки


Первое и самое важное: это небезопасно. Компилятор не будет проверять вашу работу. Так бывает всегда при использовании утверждающих функций (а также функций защиты типов), но в данном случае этот нюансы заслуживает отдельного выделения. Мы следуем на LinkedIn норме, согласно которой аннотируем подобные моменты комментариями //БЕЗОПАСНОСТЬ: — эту идею мы позаимствовали из подхода сообщества Rust к работе с блоками unsafe. (Можете заметить это в коде выше).

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

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

class Person {
  // существующая реализация...

  addAddress(address: string): this is PersonWithAddress {
    this.address = address;
  }

  addHobbies(hobbies: string[]): this is PersonWithHobbies {
    this.hobbies = hobbies;
  }

  describe(): string {
    let base = `${this.name} is a ${this.age}-year-old`;
    let location = `living in ${this.address}`;
    //                               ^^^^^^^ не существует!

    let listFormatter =
      new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
    let hobbies = listFormatter.format(this.hobbies);
    //                                      ^^^^^^^ не существует!

    return `${base} ${location}, who likes to do ${hobbies}`;
  }
}

Третье – подобное мутирование объектов негативно сказывается на быстродействии: виртуальные машины JS лучше всего оптимизируют объекты с согласующимися формами, а этот приём их согласованность нарушает.

Если говорить в целом, то единственной причиной использовать этот подход может стать моделирование существующих API, которые действуют подобным образом, и при этом у вас нет возможности их изменить.

Обобщение нецелевого использования


На деле можно обобщить этот приём до утилиты, представляющей подобные операции расширения на основе мутации:

function extend<T extends object, U extends object>(
  value: T,
  extension: U
): asserts value is T & U {
  Object.assign(value, extension);
}

Это позволит нам работать подобным образом с любыми типами объектов:

let person = {
  name: 'Chris',
  age: 34,
};

// Работает! 						
Источник: https://habr.com/ru/company/ruvds/blog/664600/


Интересные статьи

Интересные статьи

Архитектуры микросервисов продолжают развиваться в инженерных организациях, поскольку команды стремятся увеличить скорость разработки. Микросервисы продвигают идею модуль...
Так получилось что изучать Vue я начал месяц назад с версии Vue 3. Предварительно было заявлено что Vue 3 переписана заново на typescript. Признаться того же самого я ожидал и от новой ве...
Продолжу выкладывание примеров использования GitHub'а как инструмента обучения.Рассмотрим версию работы нескольких команд над одним большим проектом с подпроектами. ...
Что такое SEH Из всех механизмов, предоставляемых операционными системами семейства Windows, возможно наиболее широко используемым, но не полностью документированным, является механизм с...
Ваш сайт работает на 1С-Битрикс? Каждому клиенту вы даёте собственную скидку или назначаете персональную цену на товар? Со временем в вашей 1С сложилась непростая логика ценообразования и формирования...