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

Мотивация


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

Assert-функции


Эти функции используются в 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.

Оговорки


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

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

Второе – это поможет только в том случае, если assert-функция будет частью обычного потока управления. Подобные мутации на уровне типов не задерживаются на всю жизнь объекта, как это бывает со значениями среды выполнения. Например, если у вас есть два метода класса, один из которых использует assert-функцию для обновления 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,
};

// Работает! ????
extend(person, { hobbies: ['running', 'composing', 'writing'] });
console.log(person.hobbies);

Выглядит неплохо, не так ли? Хотя, по правде говоря, есть здесь кое-какие проблемы (откройте код в песочнице TS):

// Этот фрагмент тоже проходит проверку типов! ????
extend(person, { age: "potato" });
// Пока мы не пробуем его использовать. Теперь `age` стал `never`
person.age

// ... и здесь проверка типов проходит!
extend(person, { hobbies: 123 })
// Но мы получаем тип `string[] & number`, что является абсурдом
person.hobbies + 2
person.hobbies.find((s) => s === 'wat');

// И этот вариант «работает»... но добавляет значения массива по их численным индексам
extend(person, ['a', 'b', 'c'])
console.log(person[0]); // 'a' ????

Итог


Несмотря на соблазнительность этого универсального паттерна extend, использовать его не стоит. Он будет выглядеть неплохо…ровно до того момента, пока вы не попытаетесь выяснить, почему age стал never, или получите любые другие странные результаты, которые TS будет беззаботно игнорировать.

Сноска


1. В нашем случае это была библиотека веб-отслеживания – не какого-то подлого отслеживания, а такого, что позволяет нам анализировать использование функционала, выполнять A/B тесты и т. д. – которая была написана в отношении версий Ember пятилетней давности. Она работала путём мутации экземпляра легаси Component API в процессе настройки. Вы внедряете сервис, затем во время init() (хук инициализации Ember Classic, следующий за constructor) вызываете метод сервиса setupComponent с экземпляром компонента в качестве его аргумента:

import Component from '@ember/component';
import { service } from '@ember/service';
    
export default class SomeComponent extends Component {
@service tracking;    

init() {
super.init();
this.tracking.setupComponent(this);
   }
}

Затем метод сервиса отслеживания устанавливает слушателей событий и добавляет или мутирует в компоненте кучу полей:

import Service from '@ember/service';
import { set } from '@ember/object';

export default class TrackingService extends Service {
  // много всего
  setupComponent(componentInstance) {
    const attributeBindings = component.attributeBindings || [];
    set(
      component,
      'attributeBindings',
      attributeBindings.concat(['data-control-name', 'data-control-id'])
    );

    component.on('didInsertElement', () => {
      // ...
    });
  }
}

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

Комментарии (3)


  1. sky2high0
    10.05.2022 15:26
    +1

    Статья хорошая.

    Думаю, «утверждающие функции» вообще никто не говорит. В целом такие термины не стоит переводить т.к. они напрямую растут из фактического имени функции в коде. Так что «assert-функции» вполне достаточно.


    1. Bright_Translate Автор
      10.05.2022 15:47

      Спасибо за подсказку. Хотя странно, ведь по факту именно это они и делают.


      1. mayorovp
        10.05.2022 17:53

        То же самое про "функции защиты типов" (type guards, как я понял). Эти тоже лучше бы не переводить буквально.