Skip to main content

Утилітний тип readonly у TypeScript

Readonly - це утилітний тип TypeScript, який робить кожну властивість типу T незмінною на етапі компіляції. Читати можна скільки завгодно. Записувати - ні.

Теорія

TL;DR

  • Уяви музейну вітрину: бачиш усе всередині, але нічого не можна переставити
  • Readonly<T> блокує тільки властивості верхнього рівня - вкладені об'єкти залишаються змінюваними
  • Саме ця поведінка з вкладеністю найчастіше дивує розробників
  • Використовуй, коли об'єкт передається між функціями і потрібен захист від випадкових мутацій на рівні компілятора
  • Жодного впливу на runtime - TypeScript видаляє це під час компіляції

Швидкий приклад

typescript
interface User { id: number; name: string; } const user: Readonly<User> = { id: 1, name: 'Alice' }; console.log(user.name); // 'Alice' - читання працює нормально user.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property

TypeScript додає readonly до кожного ключа User. JavaScript-код при цьому не змінюється - жодних перевірок у рантаймі.

Головна особливість: тільки перший рівень

Readonly<T> блокує прямі властивості типу T. Якщо одна з властивостей сама є об'єктом, її вміст залишається змінюваним. Я бачив, як це спантеличувало досвідчених розробників при роботі з Redux-стейтом або вкладеними конфігами: мутація компілюється без помилок і залишається непоміченою до першого дивного бага в продакшені.

typescript
interface Settings { theme: string; db: { host: string }; } const settings: Readonly<Settings> = { theme: 'dark', db: { host: 'localhost' } }; // settings.theme = 'light'; // Помилка - верхній рівень заблокований settings.db.host = 'prod'; // Ок - вкладений об'єкт не захищений

Коли використовувати

  • Об'єкт передається через кілька функцій і побічні ефекти важко відстежити
  • Конфіг або налаштування, які задаються один раз при старті застосунку
  • Дані з API, які нижче по коду не повинні змінюватись
  • Пропси React-компоненту, щоб явно позначити контракт «не мутувати»

Як це обробляє компілятор

TypeScript розгортає Readonly<T> як відображуваний тип (mapped type): { readonly [K in keyof T]: T[K] }. Компілятор проходить по кожному ключу T і додає модифікатор readonly. У JavaScript-код це не потрапляє. V8 цього ніколи не бачить.

Типові помилки

1. Очікування глибокого захисту

typescript
type Config = Readonly<{ db: { host: string } }>; const cfg: Config = { db: { host: 'localhost' } }; cfg.db.host = 'production'; // Компілюється без помилок

Виправлення: вкладай Readonly вручну або напиши рекурсивний mapped type:

typescript
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };

2. Масиви всередині Readonly приймають push

typescript
type State = Readonly<{ items: number[] }>; const state: State = { items: [1, 2] }; state.items.push(3); // Дозволено - заблоковано посилання, але не сам масив

Виправлення: Readonly<{ readonly items: readonly number[] }>.

3. Індексні сигнатури залишаються відкритими

Readonly<{ [k: string]: number }> все одно дозволяє obj['newKey'] = 1. Обгортка не блокує додавання нових динамічних ключів. Щоб заблокувати і це, пиши модифікатор явно всередині: { readonly [k: string]: number }.

Де зустрічається

  • React - function TodoItem(props: Readonly<TodoProps>) запобігає мутаціям пропсів всередині компоненту; поширений патерн у Next.js 14+
  • Redux - type State = Readonly<{ todos: Todo[] }> є в офіційних прикладах Redux Toolkit
  • Серверні конфіги - type ServerConfig = Readonly<{ port: number; dbUrl: string }> фіксує налаштування після старту
  • Zod - z.object({...}).readonly() автоматично повертає Readonly-версію виведеного типу

Питання на співбесіді

Q: Яка різниця між модифікатором readonly на властивості і Readonly<T>?
A: readonly id: number захищає одну конкретну властивість. Readonly<T> охоплює всі властивості T одночасно. В JavaScript обидва варіанти дають однаковий результат - нічого.

Q: Чи працює Readonly<T> з класами?
A: Так, але тільки для публічних властивостей екземпляра. Приватні і захищені члени mapped type не зачіпає. Для класів зазвичай зручніше писати readonly прямо в тілі класу.

Q: Як отримати повну (глибоку) незмінність?
A: Напиши рекурсивний mapped type: type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> }. Бібліотека ts-toolbelt має готовий варіант. Immer дає глибоку незмінність на рівні runtime.

Q: Чи є вплив на продуктивність?
A: Жодного. TypeScript видаляє всі утилітні типи під час компіляції. Вихідний JavaScript однаковий незалежно від того, є Readonly чи ні.

Q: Питання рівня senior - чому Readonly<{ [K: string]: T }> не блокує запис нових ключів?
A: Readonly робить незмінними значення вже існуючих ключів, але не саму можливість додавати нові. Індексна сигнатура описує патерн для динамічних ключів, і mapped type wrapper цього не змінює. Щоб заблокувати обидві операції, пиши { readonly [K: string]: T } без зовнішньої обгортки.

Приклади

Базовий: конфіг при старті застосунку

typescript
interface AppConfig { apiUrl: string; timeout: number; } const config: Readonly<AppConfig> = { apiUrl: 'https://api.example.com', timeout: 5000, }; // config.apiUrl = '/v2'; // Помилка: Cannot assign to 'apiUrl' console.log(config.apiUrl); // читання завжди працює

Конфіг задається один раз. Будь-який подальший запис - це помилка компіляції, а не тихий баг, який знайдеш тільки в рантаймі.

Середній рівень: пропси React-компоненту

typescript
interface TodoProps { id: number; text: string; done: boolean; } function TodoItem({ id, text, done }: Readonly<TodoProps>) { // id = 42; // Помилка - деструктурований пропс тільки для читання return <li>{text} {done ? '(done)' : ''}</li>; }

Власні типи React вже обгортають пропси в Readonly внутрішньо. Явне оголошення додає ясності і ловить мутації під час розробки, до того як щось потрапить у браузер.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?