Это ужасный (но очень полезный) хак, который я придумал для добавления типов в старый код. Вчера мой коллега, работающий над добавлением типов в одну из наших основных библиотек на 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, для работы которых потребуется мутация объектов).↩
sky2high0
Статья хорошая.
Думаю, «утверждающие функции» вообще никто не говорит. В целом такие термины не стоит переводить т.к. они напрямую растут из фактического имени функции в коде. Так что «assert-функции» вполне достаточно.
Bright_Translate Автор
Спасибо за подсказку. Хотя странно, ведь по факту именно это они и делают.
mayorovp
То же самое про "функции защиты типов" (type guards, как я понял). Эти тоже лучше бы не переводить буквально.