Перейти к содержимому
40 / 58

Как создать тип DeepReadonly<T>?


Полный ответ

Проблема с обычным Readonly:

TypeScript
interface State {
  user: {
    name: string;
    roles: string[];
  };
  settings: {
    theme: string;
  };
}

const state: Readonly<State> = {
  user: { name: "Alice", roles: ["admin"] },
  settings: { theme: "dark" },
};

// state.user = { ... };        // ошибка — первый уровень защищён
state.user.name = "Bob";        // НЕТ ошибки — вложенный объект мутабелен!
state.user.roles.push("editor"); // НЕТ ошибки — массив мутабелен!

Рекурсивная реализация:

TypeScript
type DeepReadonly<T> = T extends Map<infer K, infer V>
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends Set<infer U>
? ReadonlySet<DeepReadonly<U>>
: T extends ReadonlyArray<infer U>
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;

const deepState: DeepReadonly<State> = {
  user: { name: "Alice", roles: ["admin"] },
  settings: { theme: "dark" },
};

// deepState.user = { ... };           // ошибка
// deepState.user.name = "Bob";        // ошибка
// deepState.user.roles.push("editor"); // ошибка — readonly array
// deepState.settings.theme = "light"; // ошибка

Упрощённая версия для большинства случаев:

TypeScript
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;

Использование с функциями:

TypeScript
// Функция, которая гарантирует неизменность входных данных

function processConfig(config: DeepReadonly<Config>): string {
  // config.db.host = "other"; // ошибка компиляции
  return `${config.db.host}:${config.db.port}`;
}

// Freeze на уровне рантайма + типа

function deepFreeze<T extends Record<string, unknown>>(obj: T): DeepReadonly<T> {
  Object.freeze(obj);
  for (const key of Object.keys(obj)) {
    const value = obj[key];
    if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
      deepFreeze(value as Record<string, unknown>);
    }
  }
  return obj as DeepReadonly<T>;
}

const frozenConfig = deepFreeze({
    api: { url: "https:// api.example.com", timeout: 5000 },
    features: { darkMode: true },
  });
// frozenConfig.api.url = "x"; // ошибка компиляции И рантайм ошибка

Реальные кейсы

Иммутабельное состояние в Redux:

TypeScript
interface AppState {
  todos: Array<{ id: number; text: string; done: boolean }>;
  filter: "all" | "active" | "done";
}

type ReadonlyState = DeepReadonly<AppState>;

function reducer(state: ReadonlyState, action: Action): ReadonlyState {
  switch (action.type) {
    case "ADD_TODO":
    return {
      ...state,
      todos: [...state.todos, { id: Date.now(), text: action.text, done: false }],
    }; // state.todos.push(...) — ошибка компиляции
    default:
    return state;
  }
}

Ошибка: забыть про массивы:

TypeScript
// ❌ Только readonly на объекте — массив всё ещё мутабельный
type BadReadonly<T> = { readonly [K in keyof T]: T[K] };
type Test = BadReadonly<{ items: number[] }>;
// items: readonly number[] — нет! items: number[] — мутабельный

// ✅ DeepReadonly превращает number[] в readonly number[]
type Good = DeepReadonly<{ items: number[] }>; // items: readonly number[] — пуш невозможен

Резюме

DeepReadonly<T> рекурсивно добавляет readonly ко всем свойствам и превращает массивы в readonly массивы. Добавьте обработку Map, Set и Date для полноты. Используйте в паре с Object.freeze для защиты и на уровне типов, и в рантайме.

Как создать тип DeepReadonly<T>? | JScriptiser