Readonly, readonly, const


Астрологи объявили неделю immutable & const в JS/TS. Продолжая тему иммутабельности в JS, хочется затронуть TypeScript. На этом языке можно объявлять неизменяемые объекты чисто символически, которые будут в рантайме мутировать. А можно создавать тру константы объекты с дополнительной защитой, которую может дать TypeScript.

Рассмотрим простой пример:


Все работает логично, TS не позволяет модифицировать ссылку на объект, но позволяет менять сам объект. Про это было сказано в статье:

https://medium.com/@frontman/const-%D0%B2-js-%D0%B4%D0%B5%D0%BB%D0%B0%D0%B5%D1%82-%D1%81%D0%B2%D0%BE%D1%8E-%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D1%83-%D0%BF%D1%80%D0%B0%D0%B2%D0%B8%D0%BB%D1%8C%D0%BD%D0%BE-b346353d9cce

Там же в статье мы говорили, что если хотим сделать константный объект, то нам нужно помимо использования слова const еще и сделать freeze объекта. Пробуем:


Как видим TS правильно отрабатывает ситуацию и сообщает на этапе компиляции (транспиляции), что мы пытаемся модифицировать замороженное свойство. Но глубокой заморозки нет, вложенные поля объекта будут модифицироваться. Пробуем функцию глубокой заморозки, которую мы написали в прошлой статье:

const obj = immutable({ ... })

И внезапно TS перестает ругаться на модификацию. Т.е. для runtime мы создали иммутабельный объект, а для статического анализатора это неочевидно. И при рефакторинге очень легко сломать код, так как TS не будет сообщать о модификации. Что делать?

Readonly and readonly

В TypeScript есть механизмы для описывания иммутабельных объектов. Есть модификатор полей свойств и есть отдельный генерик тип.

Модификатор readonly

Ключевое слово readonly — позволяет определить свойства, которые доступны только для чтения. Этот модификатор является дополнением модификаторов public, private и protected и является частью системы типов TS. Он используется компилятором только для проверки.

Так как модификатор readonly — это только артефакт при компиляции, то он не является защитой от присваивания значений в рантайме и нам нужно самим обеспечивать иммутабельность для рантайма.

Модификатор readonly можно добавлять только в описания свойств, поэтому нам нужно описать интерфейс или тип и уже работать с ним. В принципе это и есть TS Way — четкое фиксирование структур. Убираем гибкость объектов в пользу определенности. Не должно быть внезапных свойств, все должно быть заранее продумано и объявлено.

type MyConstObject = {
readonly foo: number,
readonly a: {
readonly b: {
readonly c: 'string'
}
}
}
// или
interface MyConstObject {
readonly foo: number,
readonly a: {
readonly b: {
readonly c: 'string'
}
}
}


Все работает корректно. А что если у нас простой объект, без вложенных элементов или мы хотим заморозить только первый уровень? Нам же не надо использовать функцию и мы можем обойтись Object.freeze (или нам нужно и мы просто хотим). Пробуем:


Получаем сообщение об ошибке, так как у нас несовпадение типов. Да, это минус сильной явной типизации — нужно за всем следить и явно указывать что вы ожидаете. Магии не будет. Самый простой способ починить эту ошибку и рассказать компилятору что мы делаем — это использовать Type Casting:

const obj :MyConstObject = <MyConstObject>Object.freeze({ ... })
// или
const obj :MyConstObject = Object.freeze({ ... }) as MyConstObject

Второй вариант можно использовать как в TS так и в TSX для React. Первый вариант только для TS. Но когда мы разбирали ошибки, то выяснили что существует интерфейс, а почитав документацию узнали что их целое семейство.

Интерфейс Readonly и его наследники

Чтобы не расставлять модификатор readonly ко всем свойствам (представьте их штук 30 в объекте), мы можем использовать генерик Readonly. Более того, мы можем сделать более грамотный код, описав интерфейс объекта и отдельно его иммутабельную версию. Это позволит более гибко переиспользовать интерфейсы и кастомные типы.

// Базовое описание объекта, можем переиспользовать и мутировать
interface MyObject {
foo: number,
a: {
b: {
c: 'string'
}
}
}
// Добавляем иммутабельности
type MyObjectConst = Readonly<MyObject>;
// Используем
const
obj :MyObjectConst = <MyObjectConst> Object.freeze({ ... })

Такой подход более гибкий и правильный и соответствует принципам SOLID.

Иммутабельный массив

С помощью генериков в TypeScript мы можем описывать любые структуры данных. Например мы хотим создать readonly массив. Для этого опишем такую структуру:

interface ImmutableArray<T> {
readonly [key: number]: T
}
const arr: ImmutableArray<number> = [1, 2, 3];
arr[0] = 4; // Index signature in type 'ImmutableArray<number>' only permits reading.

Но в TS уже есть встроенные типы (интерфейсы) для некоторых базовых структур данных. Например есть такой генерик — ReadonlyArray. И поэтому нам даже не нужно самим описывать свой тип для массива, можем использовать готовый:

const arr: ReadonlyArray<number> = [1, 2, 3];
arr[0] = 4;

И да, не забываем добавить для рантайма фриз объекта:

const arr: ReadonlyArray<number> = Object.freeze([1,2,3]);
// или
const arr: ReadonlyArray<number> = [1,2,3];
Object.freeze([1,2,3]); // массив фризится по ссылке

На сегодня TypeScript стал очень популярным и его выбирают бэкенд разработчики на разных языках со строгой и явной типизацией. Поэтому есть отдельные вакансии TypeScript разработчиков.

Конкретно в этой вакансии нужен фронтендер в команду к С++ бэкендерам. И на таких собеседованиях по TypeScript помимо ООП паттернов и принципов SOLID так же могут спрашивать и про разные структуры данных. И за такие знания дают очень шикарные условия работы.