Утилітний тип readonly у TypeScript
Readonly
Теорія
TL;DR
- Уяви музейну вітрину: бачиш усе всередині, але нічого не можна переставити
Readonly<T>блокує тільки властивості верхнього рівня - вкладені об'єкти залишаються змінюваними- Саме ця поведінка з вкладеністю найчастіше дивує розробників
- Використовуй, коли об'єкт передається між функціями і потрібен захист від випадкових мутацій на рівні компілятора
- Жодного впливу на runtime - 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 propertyTypeScript додає readonly до кожного ключа User. JavaScript-код при цьому не змінюється - жодних перевірок у рантаймі.
Головна особливість: тільки перший рівень
Readonly<T> блокує прямі властивості типу T. Якщо одна з властивостей сама є об'єктом, її вміст залишається змінюваним. Я бачив, як це спантеличувало досвідчених розробників при роботі з Redux-стейтом або вкладеними конфігами: мутація компілюється без помилок і залишається непоміченою до першого дивного бага в продакшені.
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. Очікування глибокого захисту
type Config = Readonly<{ db: { host: string } }>;
const cfg: Config = { db: { host: 'localhost' } };
cfg.db.host = 'production'; // Компілюється без помилокВиправлення: вкладай Readonly вручну або напиши рекурсивний mapped type:
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };2. Масиви всередині Readonly приймають push
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 } без зовнішньої обгортки.
Приклади
Базовий: конфіг при старті застосунку
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-компоненту
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 внутрішньо. Явне оголошення додає ясності і ловить мутації під час розробки, до того як щось потрапить у браузер.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.